Compare commits
1 Commits
main
..
app-v0.1.2
| Author | SHA1 | Date | |
|---|---|---|---|
| 00213085b8 |
@@ -1,12 +0,0 @@
|
||||
# Arch Linux's rust package bakes -fuse-ld=lld into the default rustflags.
|
||||
# ring (pulled in by tauri-plugin-updater) ships C/asm objects that lld cannot
|
||||
# resolve (ring_core_* symbols). Fix: force cc as linker driver and append
|
||||
# -fuse-ld=bfd so it overrides the hardcoded -fuse-ld=lld (last flag wins).
|
||||
# bfd is always available via binutils (part of base-devel on Arch).
|
||||
#
|
||||
# NOTE: When building via makepkg (AUR), RUSTFLAGS from /etc/makepkg.conf
|
||||
# overrides target-specific rustflags here. The PKGBUILD's build() applies
|
||||
# the same fix by patching the RUSTFLAGS env var directly.
|
||||
[target.x86_64-unknown-linux-gnu]
|
||||
linker = "cc"
|
||||
rustflags = ["-C", "link-arg=-fuse-ld=bfd"]
|
||||
@@ -1,91 +0,0 @@
|
||||
// Architecture / layering guard for the feature-folder structure (PR #1225, group A1).
|
||||
//
|
||||
// Encodes the layering contract the restructure established:
|
||||
//
|
||||
// lib (feature-free infra: api clients, format, i18n, util, media, server, navigation)
|
||||
// ▲
|
||||
// store / ui (cross-cutting global stores; domain-agnostic primitives) — may import lib
|
||||
// ▲
|
||||
// cover / music-network (top-level domains — peers; may import lib, store, ui)
|
||||
// ▲
|
||||
// features/<x> (may import lib, store, ui, cover, music-network, other features via barrel only)
|
||||
// ▲
|
||||
// app (shell + bridges — may import anything)
|
||||
//
|
||||
// A lower layer may NOT import a higher one. Cross-feature access goes through the
|
||||
// `@/features/<x>` barrel only, never a deep path. No import cycles anywhere.
|
||||
//
|
||||
// Ratchet: current known violations (residual legacy dirs + documented inversions) are
|
||||
// captured in `.dependency-cruiser-known-violations.json` and ignored via `--ignore-known`
|
||||
// (see `npm run dep:check`). Any NEW violation fails CI. As the drain (group E) removes an
|
||||
// exception, regenerate the baseline so the count ratchets toward zero.
|
||||
|
||||
/** @type {import('dependency-cruiser').IConfiguration} */
|
||||
module.exports = {
|
||||
forbidden: [
|
||||
{
|
||||
name: 'no-circular',
|
||||
severity: 'error',
|
||||
comment: 'No import cycles anywhere under src/ — they make the module graph impossible to reason about.',
|
||||
from: {},
|
||||
to: { circular: true },
|
||||
},
|
||||
{
|
||||
name: 'lib-is-the-floor',
|
||||
severity: 'error',
|
||||
comment:
|
||||
'lib/** is feature-free infra and must not import a higher layer ' +
|
||||
'(features, store, ui, app, cover, music-network).',
|
||||
from: { path: '^src/lib/' },
|
||||
to: { path: '^src/(features|store|ui|app|cover|music-network)/' },
|
||||
},
|
||||
{
|
||||
name: 'no-core-to-feature',
|
||||
severity: 'error',
|
||||
comment:
|
||||
'store/** and ui/** are cross-cutting core and must not import features or the app shell ' +
|
||||
'(the inversions the seams removed — keep them out).',
|
||||
from: { path: '^src/(store|ui)/' },
|
||||
to: { path: '^src/(features|app)/' },
|
||||
},
|
||||
{
|
||||
name: 'no-deep-cross-feature',
|
||||
severity: 'error',
|
||||
comment:
|
||||
'A feature must reach another feature only through its `@/features/<x>` barrel (index), ' +
|
||||
'never a deep path. Same-feature deep imports are fine.',
|
||||
from: { path: '^src/features/([^/]+)/' },
|
||||
to: {
|
||||
path: '^src/features/([^/]+)/[^/]+',
|
||||
pathNot: [
|
||||
'^src/features/$1/', // same feature — allowed
|
||||
'^src/features/[^/]+/index\\.(ts|tsx)$', // the barrel — allowed
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
options: {
|
||||
doNotFollow: { path: 'node_modules' },
|
||||
// Type-only edges are tolerated by the iron rule (erased at runtime), but the ratchet
|
||||
// records whatever exists today regardless of kind; new edges of any kind fail.
|
||||
tsPreCompilationDeps: true,
|
||||
tsConfig: { fileName: 'tsconfig.json' },
|
||||
enhancedResolveOptions: {
|
||||
extensions: ['.ts', '.tsx', '.js', '.jsx', '.json'],
|
||||
mainFields: ['module', 'main', 'types', 'typings'],
|
||||
},
|
||||
// The layering contract is about the production module graph. Tests, test helpers,
|
||||
// ambient declarations and non-src trees are not part of it.
|
||||
exclude: {
|
||||
path: [
|
||||
'\\.test\\.(ts|tsx)$',
|
||||
'^src/test/',
|
||||
'\\.d\\.ts$',
|
||||
'^src/vite-env',
|
||||
],
|
||||
},
|
||||
reporterOptions: {
|
||||
text: { highlightFocused: true },
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,15 +0,0 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: psychotoxic
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
polar: # Replace with a single Polar username
|
||||
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
|
||||
thanks_dev: # Replace with a single thanks.dev username
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
@@ -1,138 +0,0 @@
|
||||
name: Bug report
|
||||
description: Something is broken or behaves incorrectly
|
||||
title: "[Bug]: "
|
||||
labels: ["bug", "triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to report a bug. Please fill in as much as you can — the more
|
||||
precise the report, the faster it can be reproduced and fixed.
|
||||
|
||||
**Before opening an issue:** if you're not sure whether the problem is on the Psysonic side
|
||||
or on your Subsonic-compatible server, please ask in the [Discord](https://discord.gg/AMnDRErm4u)
|
||||
or [Telegram](https://t.me/+GLBx1_xeH28xYTJi) community first.
|
||||
|
||||
- type: textarea
|
||||
id: summary
|
||||
attributes:
|
||||
label: What's wrong?
|
||||
description: A short description of the problem (one or two sentences).
|
||||
placeholder: e.g. The volume slider jumps back to 100 % every time I switch tracks.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: Numbered, minimal steps that lead to the problem.
|
||||
placeholder: |
|
||||
1. Open Psysonic and connect to my Navidrome server.
|
||||
2. Start playback of any track.
|
||||
3. Drag the volume slider to ~30 %.
|
||||
4. Press the next button.
|
||||
5. Volume slider has snapped back to 100 %.
|
||||
value: |
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: What should happen?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: What actually happens?
|
||||
description: Include any error toast or popup text exactly as shown.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Psysonic version
|
||||
description: |
|
||||
Exact version string from `Settings → About` (e.g. `1.45.2` or `1.46.0-dev`).
|
||||
placeholder: 1.45.2
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: os
|
||||
attributes:
|
||||
label: Operating system
|
||||
options:
|
||||
- Linux
|
||||
- macOS
|
||||
- Windows
|
||||
- Other (please describe in "Anything else")
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: install_source
|
||||
attributes:
|
||||
label: Install source
|
||||
description: How did you install Psysonic?
|
||||
options:
|
||||
- AUR — psysonic
|
||||
- AUR — psysonic-bin
|
||||
- .deb (Linux)
|
||||
- .rpm (Linux)
|
||||
- flakes
|
||||
- .dmg (macOS)
|
||||
- .msi installer (Windows)
|
||||
- Built from source
|
||||
- Other (please describe in "Anything else")
|
||||
|
||||
- type: dropdown
|
||||
id: server
|
||||
attributes:
|
||||
label: Subsonic-compatible server
|
||||
options:
|
||||
- Navidrome
|
||||
- Gonic
|
||||
- Airsonic
|
||||
- LMS
|
||||
- Other Subsonic-compatible server
|
||||
- Not sure / not relevant
|
||||
|
||||
- type: input
|
||||
id: server_version
|
||||
attributes:
|
||||
label: Server version
|
||||
description: If your server has a version string, paste it here. Skip if the bug is clearly UI-side.
|
||||
placeholder: e.g. Navidrome 0.55.0
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs
|
||||
description: |
|
||||
How to grab logs:
|
||||
1. `Settings → Logging → Debug` (enables verbose logging)
|
||||
2. Reproduce the bug
|
||||
3. `Settings → Logging → Export logs` and attach the resulting file or paste the relevant section below.
|
||||
|
||||
Logs help a lot, especially for playback / streaming / sync bugs.
|
||||
render: shell
|
||||
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: Screenshots / video
|
||||
description: Drag-and-drop is supported. Optional but appreciated for UI bugs.
|
||||
|
||||
- type: textarea
|
||||
id: anything_else
|
||||
attributes:
|
||||
label: Anything else
|
||||
description: Distro / WM / GPU driver, Tauri-specific quirks, theme, anything that might be relevant.
|
||||
@@ -1,16 +0,0 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Usage questions / general help
|
||||
url: https://discord.gg/AMnDRErm4u
|
||||
about: |
|
||||
Have a question, need help connecting to your server, or not sure if a behaviour is
|
||||
a bug? Please ask in the Discord community first — the issue tracker is reserved for
|
||||
confirmed bugs and feature requests.
|
||||
- name: Telegram community
|
||||
url: https://t.me/+GLBx1_xeH28xYTJi
|
||||
about: Alternative chat channel for usage questions and general discussion.
|
||||
- name: AUR packaging issues
|
||||
url: https://aur.archlinux.org/packages/psysonic
|
||||
about: |
|
||||
For problems specific to the AUR `psysonic` or `psysonic-bin` packages (build failures,
|
||||
dependency mismatches, AUR metadata), please comment on the AUR page first.
|
||||
@@ -1,43 +0,0 @@
|
||||
name: Feature request
|
||||
description: Suggest a new feature or improvement
|
||||
title: "[Feature]: "
|
||||
labels: ["enhancement", "triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for suggesting an improvement. A short description of the use case is more
|
||||
valuable than a fully-specified solution — the implementation usually shifts during
|
||||
review, but the underlying need stays the same.
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: What's the use case?
|
||||
description: |
|
||||
What are you trying to do that's currently hard or impossible? Describe the workflow
|
||||
or situation, not the proposed solution.
|
||||
placeholder: |
|
||||
e.g. When I'm on a long flight I want to keep listening to my offline-cached tracks,
|
||||
but if I open Psysonic without an internet connection it shows the connection
|
||||
screen instead of letting me into the player.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: Proposed solution
|
||||
description: How do you imagine this working? Sketches, UI ideas, command names — anything goes.
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives considered
|
||||
description: Workarounds you've tried, or other approaches that didn't fit.
|
||||
|
||||
- type: textarea
|
||||
id: anything_else
|
||||
attributes:
|
||||
label: Anything else
|
||||
description: Links to related issues, similar requests, screenshots, etc.
|
||||
@@ -1,46 +0,0 @@
|
||||
# Dependabot: security updates only (no scheduled version bumps).
|
||||
#
|
||||
# Version updates are disabled via open-pull-requests-limit: 0. GitHub still opens
|
||||
# PRs when Dependabot/npm/cargo audit reports a vulnerability (and related
|
||||
# transitive fixes in the same bump). Routine minor/patch upgrades are manual.
|
||||
#
|
||||
# Requires "Dependabot security updates" enabled in repo Settings → Code security.
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: npm
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
open-pull-requests-limit: 0
|
||||
labels:
|
||||
- dependencies
|
||||
- security
|
||||
groups:
|
||||
npm-security:
|
||||
applies-to: security-updates
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
- package-ecosystem: cargo
|
||||
directory: /src-tauri
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
open-pull-requests-limit: 0
|
||||
labels:
|
||||
- dependencies
|
||||
- security
|
||||
groups:
|
||||
cargo-security:
|
||||
applies-to: security-updates
|
||||
patterns:
|
||||
- "*"
|
||||
ignore:
|
||||
# Symphonia 0.6 is a coordinated migration (API break + isomp4 patch port).
|
||||
# See workdocs: internal/collaboration/tasks/2026-05-symphonia-0.6-migration/
|
||||
- dependency-name: symphonia
|
||||
versions: [">= 0.6"]
|
||||
- dependency-name: symphonia-adapter-libopus
|
||||
versions: [">= 0.3"]
|
||||
@@ -1,48 +0,0 @@
|
||||
# Hot-path source files for the per-file ≥70 % coverage gate (frontend).
|
||||
#
|
||||
# Mirrors `.github/hot-path-files.txt` for the Rust crates. Each entry is a
|
||||
# workspace-relative path; the gate script (`scripts/check-frontend-hot-path-
|
||||
# coverage.sh`) reads `coverage/coverage-summary.json` produced by
|
||||
# `vitest run --coverage` and fails the frontend-tests coverage job when a
|
||||
# listed file drops below the floor.
|
||||
#
|
||||
# Curation rule (mirrors the backend list): a file belongs here when its
|
||||
# hot-path code dominates the file and ≥70 % is a reasonable floor. Files
|
||||
# that are partially covered today but contain large untested surface area
|
||||
# (e.g. `playerStore.ts`, the unfinished half of `previewStore.ts`) live
|
||||
# OUTSIDE the gate until their tests grow. Add them as Phase 1–4 coverage
|
||||
# work lands.
|
||||
#
|
||||
# Paths follow the feature-folder layout (`src/features/**`, `src/lib/**`,
|
||||
# `src/cover/**`) after the frontend restructure; the files below moved out
|
||||
# of the old `src/utils/**` tree but their contents are unchanged.
|
||||
#
|
||||
# Deferred from the gate, with current coverage shown for reference:
|
||||
# - src/features/playback/store/playerStore.ts (40 % — F1 closed under the 50 % floor; further coverage TBD)
|
||||
# - src/store/authStore.ts (79 % — F2 cleared 60 % floor; staying out one or two PRs to verify stability)
|
||||
# - src/lib/api/subsonic.ts (13 % — F3 covered the URL-builder + parser surface; async API endpoints need axios mocking, deferred)
|
||||
|
||||
# ── extracted helpers (already at or above threshold) ────────────────
|
||||
src/cover/coverArtRegisteredSizes.ts
|
||||
src/lib/server/serverDisplayName.ts
|
||||
src/lib/server/serverMagicString.ts
|
||||
src/lib/share/shareLink.ts
|
||||
src/lib/dom/dynamicColors.ts
|
||||
src/features/playback/utils/playback/resolvePlaybackUrl.ts
|
||||
src/lib/share/copyEntityShareLink.ts
|
||||
|
||||
# ── M0: pure helpers extracted from playerStore.ts (2026-05-12) ──────
|
||||
src/lib/util/shuffleArray.ts
|
||||
src/features/playback/utils/audio/resolveReplayGainDb.ts
|
||||
src/lib/media/songToTrack.ts
|
||||
src/features/playback/utils/playback/buildInfiniteQueueCandidates.ts
|
||||
|
||||
# ── Phase B.1: pre-React bootstrap + window-kind detector (2026-05-12) ──
|
||||
src/app/windowKind.ts
|
||||
src/app/bootstrap.ts
|
||||
|
||||
# ── Phase B.2: mini-player webview tree (2026-05-12) ─────────────────
|
||||
src/app/MiniPlayerApp.tsx
|
||||
|
||||
# ── stores (added as their tests grew past the floor) ────────────────
|
||||
src/features/playback/store/previewStore.ts
|
||||
@@ -1,42 +0,0 @@
|
||||
# Hot-path source files for the per-file ≥70% coverage gate.
|
||||
#
|
||||
# Background: cucadmuh asked for ≥80% per-function coverage on functions in
|
||||
# typical user-triggered sequences. The original implementation tried to
|
||||
# parse cargo-llvm-cov per-function region data, but the metric is not
|
||||
# reliable for async state-machines (async fn body lives in synthetic
|
||||
# closures, the "main" symbol is just 1-2 regions) or generic functions
|
||||
# (every monomorphic instantiation is a separate symbol). See
|
||||
# `scripts/check-hot-path-coverage.sh` header for details.
|
||||
#
|
||||
# Switched to file-level coverage: every file listed below must stay
|
||||
# at ≥70% line coverage. The list is the subset of source files where
|
||||
# the hot-path code dominates and 70% is a reasonable floor. Files like
|
||||
# `psysonic-syncfs/src/sync/batch.rs` aren't listed because their hot-
|
||||
# path functions ARE tested (parse_subsonic_songs / estimate_track_size
|
||||
# / track_sync_info_from_subsonic_json all 100%) but the same file
|
||||
# also holds a large amount of untested admin Tauri commands that drag
|
||||
# the file aggregate down. Those are tracked individually via direct
|
||||
# unit tests, not via this gate.
|
||||
#
|
||||
# Each line is a path relative to the workspace root (so `src-tauri/...`).
|
||||
# `#` for comments. CI runs cargo-llvm-cov + this gate; PRs that drop
|
||||
# any listed file below the threshold fail the rust-tests coverage job.
|
||||
|
||||
# ── psysonic-syncfs ──────────────────────────────────────────────────
|
||||
src-tauri/crates/psysonic-syncfs/src/cache/fs_utils.rs
|
||||
src-tauri/crates/psysonic-syncfs/src/cache/offline.rs
|
||||
src-tauri/crates/psysonic-syncfs/src/file_transfer.rs
|
||||
|
||||
# ── psysonic-analysis ────────────────────────────────────────────────
|
||||
src-tauri/crates/psysonic-analysis/src/analysis_cache/store.rs
|
||||
src-tauri/crates/psysonic-analysis/src/analysis_cache/compute.rs
|
||||
|
||||
# ── psysonic-audio ───────────────────────────────────────────────────
|
||||
src-tauri/crates/psysonic-audio/src/decode.rs
|
||||
src-tauri/crates/psysonic-audio/src/stream/icy.rs
|
||||
src-tauri/crates/psysonic-audio/src/progress_task.rs
|
||||
src-tauri/crates/psysonic-audio/src/ipc.rs
|
||||
|
||||
# ── psysonic-integration ─────────────────────────────────────────────
|
||||
src-tauri/crates/psysonic-integration/src/discord.rs
|
||||
src-tauri/crates/psysonic-integration/src/navidrome/client.rs
|
||||
@@ -1,231 +0,0 @@
|
||||
/**
|
||||
* Path-aware ci-ok gate: wait for required workflow job checks on a ref, then pass
|
||||
* or fail. Mirrors path filters in frontend-tests.yml, eslint.yml, rust-tests.yml.
|
||||
*/
|
||||
|
||||
const FRONTEND_PATH_RE =
|
||||
/^(src\/|package\.json$|package-lock\.json$|vitest\.config\.ts$|vite\.config\.ts$|tsconfig\.json$|eslint\.config\.mjs$|\.dependency-cruiser\.cjs$|\.dependency-cruiser-known-violations\.json$|\.github\/workflows\/frontend-tests\.yml$|\.github\/workflows\/eslint\.yml$|\.github\/frontend-hot-path-files\.txt$|scripts\/check-frontend-hot-path-coverage\.sh$|scripts\/check-css-import-graph\.mjs$)/;
|
||||
|
||||
const RUST_PATH_RE = /^(src-tauri\/|\.github\/workflows\/rust-tests\.yml$)/;
|
||||
|
||||
const FRONTEND_JOBS = [
|
||||
'vitest run',
|
||||
'tsc --noEmit',
|
||||
'vitest --coverage (baseline + hot-path file gate)',
|
||||
'eslint',
|
||||
'dependency-cruiser',
|
||||
];
|
||||
|
||||
const RUST_JOBS = [
|
||||
'cargo test --workspace',
|
||||
'cargo clippy --workspace',
|
||||
'cargo llvm-cov (baseline + hot-path file gate)',
|
||||
];
|
||||
|
||||
const POLL_MS = 30_000;
|
||||
const TIMEOUT_MS = 90 * 60 * 1000;
|
||||
const OK_CONCLUSIONS = new Set(['success', 'neutral', 'skipped']);
|
||||
|
||||
/**
|
||||
* GitHub API hiccups (5xx, secondary rate limits, dropped connections) must
|
||||
* not fail the gate — the answer is to poll again, not to go red while the
|
||||
* real jobs are green. 4xx config errors (401/404 …) still throw.
|
||||
*/
|
||||
export function isTransientApiError(err) {
|
||||
const status = typeof err?.status === 'number' ? err.status : 0;
|
||||
return status === 0 || status === 429 || status >= 500;
|
||||
}
|
||||
|
||||
export async function withTransientRetry(label, fn, core, attempts = 5, delayMs = POLL_MS) {
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
if (!isTransientApiError(err) || attempt >= attempts) {
|
||||
throw err;
|
||||
}
|
||||
// err.message can be a whole HTML error page — log only the status.
|
||||
core.info(
|
||||
`${label}: transient API error (status=${err?.status ?? 'network'}), retry ${attempt}/${attempts - 1} in ${delayMs / 1000}s`,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function pathTriggersFrontend(file) {
|
||||
return FRONTEND_PATH_RE.test(file);
|
||||
}
|
||||
|
||||
export function pathTriggersRust(file) {
|
||||
return RUST_PATH_RE.test(file);
|
||||
}
|
||||
|
||||
export function requiredJobNames(changedFiles) {
|
||||
const names = [];
|
||||
if (changedFiles.some(pathTriggersFrontend)) {
|
||||
names.push(...FRONTEND_JOBS);
|
||||
}
|
||||
if (changedFiles.some(pathTriggersRust)) {
|
||||
names.push(...RUST_JOBS);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
export function newestChecksByName(checks, excludeRunId) {
|
||||
const newest = new Map();
|
||||
for (const check of checks) {
|
||||
if (excludeRunId && (check.details_url || '').includes(`/actions/runs/${excludeRunId}/`)) {
|
||||
continue;
|
||||
}
|
||||
const key = check.name;
|
||||
const prev = newest.get(key);
|
||||
if (!prev) {
|
||||
newest.set(key, check);
|
||||
continue;
|
||||
}
|
||||
const prevTime = Date.parse(prev.started_at || prev.completed_at || '') || 0;
|
||||
const curTime = Date.parse(check.started_at || check.completed_at || '') || 0;
|
||||
if (curTime >= prevTime) {
|
||||
newest.set(key, check);
|
||||
}
|
||||
}
|
||||
return newest;
|
||||
}
|
||||
|
||||
export function evaluateRequiredJobs(required, newestByName) {
|
||||
const pending = [];
|
||||
const failures = [];
|
||||
|
||||
for (const name of required) {
|
||||
const latest = newestByName.get(name);
|
||||
if (!latest) {
|
||||
pending.push(`${name}: not started`);
|
||||
continue;
|
||||
}
|
||||
if (latest.status !== 'completed') {
|
||||
pending.push(`${name}: status=${latest.status}`);
|
||||
continue;
|
||||
}
|
||||
if (!OK_CONCLUSIONS.has(latest.conclusion || '')) {
|
||||
failures.push(`${name}: conclusion=${latest.conclusion}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { pending, failures, done: pending.length === 0 && failures.length === 0 };
|
||||
}
|
||||
|
||||
export async function listChangedFiles(github, context) {
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
if (context.eventName === 'pull_request') {
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
owner,
|
||||
repo,
|
||||
pull_number: context.payload.pull_request.number,
|
||||
per_page: 100,
|
||||
});
|
||||
return files.map((f) => f.filename);
|
||||
}
|
||||
|
||||
if (context.eventName === 'push') {
|
||||
const before = context.payload.before;
|
||||
const after = context.sha;
|
||||
if (!before || /^0+$/.test(before)) {
|
||||
const commit = await github.rest.repos.getCommit({ owner, repo, ref: after });
|
||||
return commit.data.files?.map((f) => f.filename) ?? [];
|
||||
}
|
||||
const compare = await github.rest.repos.compareCommits({
|
||||
owner,
|
||||
repo,
|
||||
base: before,
|
||||
head: after,
|
||||
});
|
||||
return compare.data.files?.map((f) => f.filename) ?? [];
|
||||
}
|
||||
|
||||
if (context.eventName === 'workflow_run') {
|
||||
const pr = context.payload.workflow_run.pull_requests?.[0];
|
||||
if (pr?.number) {
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pr.number,
|
||||
per_page: 100,
|
||||
});
|
||||
return files.map((f) => f.filename);
|
||||
}
|
||||
const headSha = context.payload.workflow_run.head_sha;
|
||||
const commit = await github.rest.repos.getCommit({ owner, repo, ref: headSha });
|
||||
return commit.data.files?.map((f) => f.filename) ?? [];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export function resolveTargetSha(context) {
|
||||
if (context.eventName === 'pull_request') {
|
||||
return context.payload.pull_request.head.sha;
|
||||
}
|
||||
if (context.eventName === 'workflow_run') {
|
||||
return context.payload.workflow_run.head_sha;
|
||||
}
|
||||
return context.sha;
|
||||
}
|
||||
|
||||
export async function runCiOkAggregate(github, context, core) {
|
||||
const { owner, repo } = context.repo;
|
||||
const sha = resolveTargetSha(context);
|
||||
const excludeRunId = String(context.runId);
|
||||
const changedFiles = await withTransientRetry(
|
||||
'listChangedFiles',
|
||||
() => listChangedFiles(github, context),
|
||||
core,
|
||||
);
|
||||
const required = requiredJobNames(changedFiles);
|
||||
|
||||
core.info(`ci-ok @ ${sha}; ${changedFiles.length} changed file(s)`);
|
||||
if (required.length === 0) {
|
||||
core.info('No path-filtered test workflows apply — ci-ok passes.');
|
||||
return;
|
||||
}
|
||||
core.info(`Waiting for required job checks: ${required.join(', ')}`);
|
||||
|
||||
const deadline = Date.now() + TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
let checksAll;
|
||||
try {
|
||||
checksAll = await github.paginate(github.rest.checks.listForRef, {
|
||||
owner,
|
||||
repo,
|
||||
ref: sha,
|
||||
per_page: 100,
|
||||
});
|
||||
} catch (err) {
|
||||
if (!isTransientApiError(err)) {
|
||||
throw err;
|
||||
}
|
||||
// Same as an inconclusive poll: wait out the hiccup, the 90-minute
|
||||
// deadline stays the backstop.
|
||||
core.info(`checks.listForRef: transient API error (status=${err?.status ?? 'network'}) — retrying next poll`);
|
||||
await new Promise((resolve) => setTimeout(resolve, POLL_MS));
|
||||
continue;
|
||||
}
|
||||
const newestByName = newestChecksByName(checksAll, excludeRunId);
|
||||
const { pending, failures, done } = evaluateRequiredJobs(required, newestByName);
|
||||
|
||||
if (failures.length > 0) {
|
||||
core.setFailed(`Required checks failed:\n${failures.join('\n')}`);
|
||||
return;
|
||||
}
|
||||
if (done) {
|
||||
core.info('All required checks are green.');
|
||||
return;
|
||||
}
|
||||
|
||||
core.info(`Pending (${pending.length}): ${pending.join('; ')}`);
|
||||
await new Promise((resolve) => setTimeout(resolve, POLL_MS));
|
||||
}
|
||||
|
||||
core.setFailed(`Timed out after ${TIMEOUT_MS / 60_000} minutes waiting for: ${required.join(', ')}`);
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
evaluateRequiredJobs,
|
||||
isTransientApiError,
|
||||
newestChecksByName,
|
||||
pathTriggersFrontend,
|
||||
pathTriggersRust,
|
||||
requiredJobNames,
|
||||
withTransientRetry,
|
||||
} from './ci-ok-aggregate.mjs';
|
||||
|
||||
test('pathTriggersFrontend matches frontend workflow paths', () => {
|
||||
assert.equal(pathTriggersFrontend('src/App.tsx'), true);
|
||||
assert.equal(pathTriggersFrontend('eslint.config.mjs'), true);
|
||||
assert.equal(pathTriggersFrontend('README.md'), false);
|
||||
});
|
||||
|
||||
test('pathTriggersRust matches rust workflow paths', () => {
|
||||
assert.equal(pathTriggersRust('src-tauri/src/lib.rs'), true);
|
||||
assert.equal(pathTriggersRust('src/App.tsx'), false);
|
||||
});
|
||||
|
||||
test('requiredJobNames unions frontend and rust jobs', () => {
|
||||
const names = requiredJobNames(['src/foo.ts', 'src-tauri/bar.rs']);
|
||||
assert.ok(names.includes('eslint'));
|
||||
assert.ok(names.includes('cargo test --workspace'));
|
||||
});
|
||||
|
||||
test('evaluateRequiredJobs fails on red conclusions', () => {
|
||||
const newest = newestChecksByName([
|
||||
{
|
||||
name: 'eslint',
|
||||
status: 'completed',
|
||||
conclusion: 'failure',
|
||||
started_at: '2026-01-01T00:00:00Z',
|
||||
details_url: '',
|
||||
},
|
||||
]);
|
||||
const result = evaluateRequiredJobs(['eslint'], newest);
|
||||
assert.equal(result.done, false);
|
||||
assert.equal(result.failures.length, 1);
|
||||
});
|
||||
|
||||
test('evaluateRequiredJobs passes when all required jobs succeeded', () => {
|
||||
const checks = ['eslint', 'vitest run'].map((name) => ({
|
||||
name,
|
||||
status: 'completed',
|
||||
conclusion: 'success',
|
||||
started_at: '2026-01-01T00:00:00Z',
|
||||
details_url: '',
|
||||
}));
|
||||
const result = evaluateRequiredJobs(['eslint', 'vitest run'], newestChecksByName(checks));
|
||||
assert.equal(result.done, true);
|
||||
});
|
||||
|
||||
test('isTransientApiError treats 5xx, 429 and network errors as transient', () => {
|
||||
assert.equal(isTransientApiError({ status: 503 }), true);
|
||||
assert.equal(isTransientApiError({ status: 500 }), true);
|
||||
assert.equal(isTransientApiError({ status: 429 }), true);
|
||||
assert.equal(isTransientApiError(new Error('socket hang up')), true);
|
||||
assert.equal(isTransientApiError({ status: 404 }), false);
|
||||
assert.equal(isTransientApiError({ status: 401 }), false);
|
||||
});
|
||||
|
||||
const silentCore = { info: () => {} };
|
||||
|
||||
test('withTransientRetry retries transient errors and returns the late success', async () => {
|
||||
let calls = 0;
|
||||
const result = await withTransientRetry(
|
||||
'test',
|
||||
async () => {
|
||||
calls += 1;
|
||||
if (calls < 3) {
|
||||
const err = new Error('unavailable');
|
||||
err.status = 503;
|
||||
throw err;
|
||||
}
|
||||
return 'ok';
|
||||
},
|
||||
silentCore,
|
||||
5,
|
||||
1,
|
||||
);
|
||||
assert.equal(result, 'ok');
|
||||
assert.equal(calls, 3);
|
||||
});
|
||||
|
||||
test('withTransientRetry rethrows non-transient errors immediately', async () => {
|
||||
let calls = 0;
|
||||
await assert.rejects(
|
||||
withTransientRetry(
|
||||
'test',
|
||||
async () => {
|
||||
calls += 1;
|
||||
const err = new Error('not found');
|
||||
err.status = 404;
|
||||
throw err;
|
||||
},
|
||||
silentCore,
|
||||
5,
|
||||
1,
|
||||
),
|
||||
/not found/,
|
||||
);
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
test('withTransientRetry gives up after the attempt budget', async () => {
|
||||
let calls = 0;
|
||||
await assert.rejects(
|
||||
withTransientRetry(
|
||||
'test',
|
||||
async () => {
|
||||
calls += 1;
|
||||
const err = new Error('unavailable');
|
||||
err.status = 503;
|
||||
throw err;
|
||||
},
|
||||
silentCore,
|
||||
3,
|
||||
1,
|
||||
),
|
||||
/unavailable/,
|
||||
);
|
||||
assert.equal(calls, 3);
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
name: ci-main
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_run:
|
||||
workflows: [frontend-tests, rust-tests, eslint]
|
||||
types: [completed]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
checks: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
ci-ok:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.event.workflow_run.head_sha || github.sha }}
|
||||
- name: aggregate required checks
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const { runCiOkAggregate } = await import('${{ github.workspace }}/.github/scripts/ci-ok-aggregate.mjs');
|
||||
await runCiOkAggregate(github, context, core);
|
||||
@@ -1,66 +0,0 @@
|
||||
name: eslint
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'vitest.config.ts'
|
||||
- 'vite.config.ts'
|
||||
- 'tsconfig.json'
|
||||
- 'eslint.config.mjs'
|
||||
- '.dependency-cruiser.cjs'
|
||||
- '.dependency-cruiser-known-violations.json'
|
||||
- '.github/workflows/eslint.yml'
|
||||
- '.github/frontend-hot-path-files.txt'
|
||||
- 'scripts/check-frontend-hot-path-coverage.sh'
|
||||
- 'scripts/check-css-import-graph.mjs'
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'vitest.config.ts'
|
||||
- 'vite.config.ts'
|
||||
- 'tsconfig.json'
|
||||
- 'eslint.config.mjs'
|
||||
- '.dependency-cruiser.cjs'
|
||||
- '.dependency-cruiser-known-violations.json'
|
||||
- '.github/workflows/eslint.yml'
|
||||
- '.github/frontend-hot-path-files.txt'
|
||||
- 'scripts/check-frontend-hot-path-coverage.sh'
|
||||
- 'scripts/check-css-import-graph.mjs'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
eslint:
|
||||
name: eslint
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
cache: 'npm'
|
||||
- run: npm ci
|
||||
- name: eslint
|
||||
run: npm run lint
|
||||
|
||||
dependency-cruiser:
|
||||
name: dependency-cruiser
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
cache: 'npm'
|
||||
- run: npm ci
|
||||
- name: layering + cycle guard
|
||||
run: npm run dep:check
|
||||
@@ -1,86 +0,0 @@
|
||||
name: frontend-tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'vitest.config.ts'
|
||||
- 'vite.config.ts'
|
||||
- 'tsconfig.json'
|
||||
- 'eslint.config.mjs'
|
||||
- '.github/workflows/frontend-tests.yml'
|
||||
- '.github/frontend-hot-path-files.txt'
|
||||
- 'scripts/check-frontend-hot-path-coverage.sh'
|
||||
- 'scripts/check-css-import-graph.mjs'
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'vitest.config.ts'
|
||||
- 'vite.config.ts'
|
||||
- 'tsconfig.json'
|
||||
- 'eslint.config.mjs'
|
||||
- '.github/workflows/frontend-tests.yml'
|
||||
- '.github/frontend-hot-path-files.txt'
|
||||
- 'scripts/check-frontend-hot-path-coverage.sh'
|
||||
- 'scripts/check-css-import-graph.mjs'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: vitest run
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
cache: 'npm'
|
||||
- run: npm ci
|
||||
- name: vitest
|
||||
run: npm test
|
||||
|
||||
typecheck:
|
||||
name: tsc --noEmit
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
cache: 'npm'
|
||||
- run: npm ci
|
||||
- run: npm run prebuild:release-notes
|
||||
- name: tsc
|
||||
run: npx tsc --noEmit
|
||||
|
||||
coverage:
|
||||
name: vitest --coverage (baseline + hot-path file gate)
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
cache: 'npm'
|
||||
- name: install jq
|
||||
run: sudo apt-get update && sudo apt-get install -y jq
|
||||
- run: npm ci
|
||||
- run: npm run prebuild:release-notes
|
||||
- name: vitest run --coverage
|
||||
run: npx vitest run --coverage
|
||||
- name: hot-path file coverage gate
|
||||
run: bash scripts/check-frontend-hot-path-coverage.sh
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: frontend-coverage-lcov
|
||||
path: coverage/lcov.info
|
||||
if-no-files-found: error
|
||||
@@ -1,33 +0,0 @@
|
||||
name: Next Channel
|
||||
|
||||
# workflow_dispatch: set "Use workflow from branch" to `main` so reusable-channel-publish.yml
|
||||
# is the latest (tag guards, etc.). The tree built for artifacts is always ref `next` below.
|
||||
on:
|
||||
push:
|
||||
branches: [next]
|
||||
# Merging auto PR "chore(nix): refresh lock …" only touches these paths — do not
|
||||
# re-run the full channel (would loop: publish → verify-nix PR → merge → push → publish).
|
||||
paths-ignore:
|
||||
- "flake.lock"
|
||||
- "nix/**"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
source_only:
|
||||
description: "Create/refresh tagged release only (skip platform builds and verify-nix)"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
publish-next:
|
||||
uses: ./.github/workflows/reusable-channel-publish.yml
|
||||
with:
|
||||
channel: next
|
||||
# Always build from channel branch; do not tie checkout to workflow_dispatch UI branch.
|
||||
source_ref: next
|
||||
target_branch: next
|
||||
prerelease: true
|
||||
draft_release: true
|
||||
verify_nix: ${{ !((github.event_name == 'workflow_dispatch' && github.event.inputs.source_only == 'true') || (github.event_name == 'push' && contains(github.event.head_commit.message, '[source-only]'))) }}
|
||||
build_platform_artifacts: ${{ !((github.event_name == 'workflow_dispatch' && github.event.inputs.source_only == 'true') || (github.event_name == 'push' && contains(github.event.head_commit.message, '[source-only]'))) }}
|
||||
secrets: inherit
|
||||
@@ -1,70 +0,0 @@
|
||||
# Keeps `nix/upstream-sources.json` (`npmDepsHash`) in sync with `package-lock.json`.
|
||||
# Runs in CI with Nix — contributors do not need Nix locally.
|
||||
#
|
||||
# Skips fork PRs (cannot push to the contributor branch); after merge to main,
|
||||
# next, or release, the push workflow updates the hash on that branch if needed.
|
||||
name: Sync Nix npmDepsHash
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, next, release]
|
||||
paths:
|
||||
- package-lock.json
|
||||
- package.json
|
||||
- nix/upstream-sources.json
|
||||
pull_request:
|
||||
paths:
|
||||
- package-lock.json
|
||||
- package.json
|
||||
- nix/upstream-sources.json
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: nix-npm-hash-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
# Fork PRs: token cannot push to the fork — merge to main will run the push workflow instead.
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || github.ref }}
|
||||
|
||||
- name: Install Nix
|
||||
uses: DeterminateSystems/nix-installer-action@v15
|
||||
|
||||
- name: Recompute npmDepsHash from package-lock.json
|
||||
run: |
|
||||
set -euo pipefail
|
||||
HASH="$(nix run nixpkgs/nixos-unstable#prefetch-npm-deps -- package-lock.json)"
|
||||
echo "Computed npmDepsHash: $HASH"
|
||||
TMP="$(mktemp)"
|
||||
jq --arg h "$HASH" '.npmDepsHash = $h' nix/upstream-sources.json > "$TMP"
|
||||
mv "$TMP" nix/upstream-sources.json
|
||||
|
||||
- name: Commit and push if changed
|
||||
env:
|
||||
PR_HEAD_REF: ${{ github.event.pull_request.head.ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add nix/upstream-sources.json
|
||||
if git diff --cached --quiet; then
|
||||
echo "npmDepsHash already matches package-lock.json — nothing to commit."
|
||||
exit 0
|
||||
fi
|
||||
git commit -m "chore(nix): sync npmDepsHash with package-lock.json"
|
||||
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
|
||||
git push origin "HEAD:${PR_HEAD_REF}"
|
||||
else
|
||||
git push origin "HEAD:${{ github.ref_name }}"
|
||||
fi
|
||||
@@ -1,107 +0,0 @@
|
||||
name: Promote main to next
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
source_branch:
|
||||
description: "Source branch to promote into next (default main). Required ci-ok checks run only when this is main."
|
||||
required: false
|
||||
default: main
|
||||
type: string
|
||||
source_only:
|
||||
description: "Create/refresh tagged release only (skip platform builds and verify-nix)"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
validate-main:
|
||||
if: inputs.source_branch == 'main'
|
||||
uses: ./.github/workflows/validate-main-green-ci.yml
|
||||
with:
|
||||
branch: ${{ inputs.source_branch }}
|
||||
required_contexts: ci-ok|ci-main / ci-ok
|
||||
|
||||
promote:
|
||||
needs: validate-main
|
||||
if: ${{ !cancelled() && (needs.validate-main.result == 'success' || needs.validate-main.result == 'skipped') }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
actions: write
|
||||
env:
|
||||
SOURCE_BRANCH: ${{ inputs.source_branch }}
|
||||
steps:
|
||||
- name: note when CI gate was skipped
|
||||
if: inputs.source_branch != 'main'
|
||||
run: |
|
||||
echo "Skipping required-check validation: source_branch is '${{ inputs.source_branch }}', not main."
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: next
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: lts/*
|
||||
- name: fast-forward next to source branch tip
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch origin "$SOURCE_BRANCH" next
|
||||
# Reset channel branch to source snapshot, then create a fresh RC bump commit.
|
||||
git reset --hard "origin/$SOURCE_BRANCH"
|
||||
- name: bump package version to next RC
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch --tags origin
|
||||
CURRENT_VERSION="$(node -p 'require("./package.json").version')"
|
||||
BASE_VERSION="$(node -p 'const v=require("./package.json").version; const m=v.match(/^(\d+\.\d+\.\d+)/); if(!m){throw new Error("Invalid version: "+v)}; m[1]')"
|
||||
MAX_RC="$(git tag -l "app-v${BASE_VERSION}-rc.*" | sed -E 's/.*-rc\.([0-9]+)$/\1/' | sort -n | tail -n1)"
|
||||
if [[ -z "$MAX_RC" ]]; then
|
||||
NEXT_RC=1
|
||||
else
|
||||
NEXT_RC=$((MAX_RC + 1))
|
||||
fi
|
||||
TARGET_VERSION="${BASE_VERSION}-rc.${NEXT_RC}"
|
||||
if [[ "$CURRENT_VERSION" == "$TARGET_VERSION" ]]; then
|
||||
echo "package.json already uses $TARGET_VERSION"
|
||||
exit 0
|
||||
fi
|
||||
npm version --no-git-tag-version "$TARGET_VERSION"
|
||||
- name: sync Tauri version from package.json
|
||||
run: node scripts/sync-tauri-version-from-package.js
|
||||
- name: commit RC version bump
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add package.json package-lock.json src-tauri/Cargo.toml src-tauri/Cargo.lock src-tauri/tauri.conf.json
|
||||
if git diff --cached --quiet; then
|
||||
echo "No version bump changes to commit."
|
||||
exit 0
|
||||
fi
|
||||
NEW_VERSION="$(node -p 'require("./package.json").version')"
|
||||
MSG="chore(release): bump next channel to ${NEW_VERSION}"
|
||||
if [[ "${{ inputs.source_only }}" == "true" ]]; then
|
||||
MSG="${MSG} [source-only]"
|
||||
fi
|
||||
git commit -m "$MSG"
|
||||
- name: push next
|
||||
run: git push --force-with-lease origin HEAD:next
|
||||
- name: dispatch Next Channel workflow
|
||||
uses: actions/github-script@v9
|
||||
env:
|
||||
SOURCE_ONLY: ${{ inputs.source_only && 'true' || 'false' }}
|
||||
with:
|
||||
script: |
|
||||
const sourceOnly = process.env.SOURCE_ONLY === "true";
|
||||
await github.rest.actions.createWorkflowDispatch({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: "next.yml",
|
||||
ref: "main",
|
||||
inputs: {
|
||||
source_only: sourceOnly ? "true" : "false",
|
||||
},
|
||||
});
|
||||
core.info(`Dispatched next.yml with source_only=${sourceOnly}`);
|
||||
@@ -1,79 +0,0 @@
|
||||
name: Promote next to release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
source_only:
|
||||
description: "Create/refresh tagged release only (skip platform builds and verify-nix)"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
promote:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
actions: write
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: release
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: lts/*
|
||||
- name: fast-forward release to next
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch origin next release
|
||||
# Reset stable channel to next snapshot, then finalize version on top.
|
||||
git reset --hard origin/next
|
||||
- name: finalize package version for release
|
||||
run: |
|
||||
set -euo pipefail
|
||||
CURRENT_VERSION="$(node -p 'require("./package.json").version')"
|
||||
FINAL_VERSION="$(node -p 'const v=require("./package.json").version; const m=v.match(/^(\d+\.\d+\.\d+)/); if(!m){throw new Error("Invalid version: "+v)}; m[1]')"
|
||||
if [[ "$CURRENT_VERSION" == "$FINAL_VERSION" ]]; then
|
||||
echo "package.json is already final: $FINAL_VERSION"
|
||||
exit 0
|
||||
fi
|
||||
npm version --no-git-tag-version "$FINAL_VERSION"
|
||||
- name: sync Tauri version from package.json
|
||||
run: node scripts/sync-tauri-version-from-package.js
|
||||
- name: commit final version bump
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add package.json package-lock.json src-tauri/Cargo.toml src-tauri/Cargo.lock src-tauri/tauri.conf.json
|
||||
if git diff --cached --quiet; then
|
||||
echo "No finalization changes to commit."
|
||||
exit 0
|
||||
fi
|
||||
NEW_VERSION="$(node -p 'require("./package.json").version')"
|
||||
MSG="chore(release): finalize release version ${NEW_VERSION}"
|
||||
if [[ "${{ inputs.source_only }}" == "true" ]]; then
|
||||
MSG="${MSG} [source-only]"
|
||||
fi
|
||||
git commit -m "$MSG"
|
||||
- name: push release
|
||||
run: git push --force-with-lease origin HEAD:release
|
||||
- name: dispatch Release Channel workflow
|
||||
uses: actions/github-script@v9
|
||||
env:
|
||||
SOURCE_ONLY: ${{ inputs.source_only && 'true' || 'false' }}
|
||||
with:
|
||||
script: |
|
||||
const sourceOnly = process.env.SOURCE_ONLY === "true";
|
||||
await github.rest.actions.createWorkflowDispatch({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: "release.yml",
|
||||
ref: "main",
|
||||
inputs: {
|
||||
source_only: sourceOnly ? "true" : "false",
|
||||
},
|
||||
});
|
||||
core.info(`Dispatched release.yml with source_only=${sourceOnly}`);
|
||||
@@ -1,30 +1,57 @@
|
||||
name: Release Channel
|
||||
|
||||
# workflow_dispatch: use workflow from `main` for latest publish logic; checkout ref is always `release`.
|
||||
name: Release
|
||||
on:
|
||||
push:
|
||||
branches: [release]
|
||||
# Same nix-refresh loop as next.yml — ignore lock-only merges from verify-nix PRs.
|
||||
paths-ignore:
|
||||
- "flake.lock"
|
||||
- "nix/**"
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
source_only:
|
||||
description: "Create/refresh tagged release only (skip platform builds and verify-nix)"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
publish-release:
|
||||
uses: ./.github/workflows/reusable-channel-publish.yml
|
||||
with:
|
||||
channel: release
|
||||
source_ref: release
|
||||
target_branch: release
|
||||
prerelease: false
|
||||
draft_release: true
|
||||
verify_nix: ${{ !((github.event_name == 'workflow_dispatch' && github.event.inputs.source_only == 'true') || (github.event_name == 'push' && contains(github.event.head_commit.message, '[source-only]'))) }}
|
||||
build_platform_artifacts: ${{ !((github.event_name == 'workflow_dispatch' && github.event.inputs.source_only == 'true') || (github.event_name == 'push' && contains(github.event.head_commit.message, '[source-only]'))) }}
|
||||
secrets: inherit
|
||||
release:
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
settings:
|
||||
- platform: 'macos-latest'
|
||||
args: '--target aarch64-apple-darwin'
|
||||
- platform: 'macos-latest'
|
||||
args: '--target x86_64-apple-darwin'
|
||||
- platform: 'ubuntu-22.04'
|
||||
args: ''
|
||||
- platform: 'windows-latest'
|
||||
args: ''
|
||||
|
||||
runs-on: ${{ matrix.settings.platform }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: install dependencies (ubuntu only)
|
||||
if: matrix.settings.platform == 'ubuntu-22.04'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
|
||||
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
|
||||
- name: install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.settings.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
|
||||
- name: install npm dependencies
|
||||
run: npm install
|
||||
|
||||
- uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
tagName: app-v__VERSION__
|
||||
releaseName: 'Psysonic v__VERSION__'
|
||||
releaseBody: 'See the assets to download this version and install.'
|
||||
releaseDraft: false
|
||||
prerelease: false
|
||||
args: ${{ matrix.settings.args }}
|
||||
|
||||
@@ -1,489 +0,0 @@
|
||||
name: Reusable Channel Publish
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
channel:
|
||||
description: "Delivery channel name (release/next)"
|
||||
required: true
|
||||
type: string
|
||||
source_ref:
|
||||
description: "Git ref to build from"
|
||||
required: true
|
||||
type: string
|
||||
target_branch:
|
||||
description: "Branch that receives nix refresh PRs"
|
||||
required: true
|
||||
type: string
|
||||
prerelease:
|
||||
description: "Mark GitHub release as prerelease"
|
||||
required: true
|
||||
type: boolean
|
||||
draft_release:
|
||||
description: "Create release as draft"
|
||||
required: true
|
||||
type: boolean
|
||||
verify_nix:
|
||||
description: "Run verify-nix job"
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
build_platform_artifacts:
|
||||
description: "Build and upload macOS/Windows/Linux artifacts + latest.json"
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
# Refresh npmDepsHash + flake.lock on the channel branch *before* tagging.
|
||||
# Promote workflows push with GITHUB_TOKEN (no downstream workflow runs), and the
|
||||
# old post-tag verify-nix PR landed after app-v* tags were already cut.
|
||||
prepare-nix-sources:
|
||||
if: ${{ inputs.verify_nix }}
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
outputs:
|
||||
source_commit_sha: ${{ steps.final-sha.outputs.value }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ inputs.target_branch }}
|
||||
- name: install Nix
|
||||
uses: DeterminateSystems/nix-installer-action@v15
|
||||
- name: configure Cachix (managed signing)
|
||||
uses: cachix/cachix-action@v15
|
||||
with:
|
||||
name: psysonic
|
||||
authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}
|
||||
- name: compute npmDepsHash from package-lock.json
|
||||
id: npm-hash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
HASH="$(nix run nixpkgs/nixos-unstable#prefetch-npm-deps -- package-lock.json)"
|
||||
echo "hash=$HASH" >> "$GITHUB_OUTPUT"
|
||||
echo "Computed npmDepsHash: $HASH"
|
||||
- name: write npmDepsHash into nix/upstream-sources.json
|
||||
run: |
|
||||
set -euo pipefail
|
||||
HASH='${{ steps.npm-hash.outputs.hash }}'
|
||||
jq --arg h "$HASH" '.npmDepsHash = $h' nix/upstream-sources.json > nix/upstream-sources.json.new
|
||||
mv nix/upstream-sources.json.new nix/upstream-sources.json
|
||||
- name: refresh flake.lock (nixpkgs pin)
|
||||
run: nix flake update --accept-flake-config
|
||||
- name: verify nix build + push to Cachix
|
||||
run: |
|
||||
set -euo pipefail
|
||||
nix build .#psysonic --accept-flake-config --no-link --print-build-logs
|
||||
nix path-info --recursive .#psysonic | cachix push psysonic
|
||||
- name: commit and push refreshed lock and hash (if changed)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add flake.lock nix/upstream-sources.json
|
||||
if git diff --cached --quiet; then
|
||||
echo "flake.lock / nix/upstream-sources.json unchanged — nothing to commit."
|
||||
exit 0
|
||||
fi
|
||||
VERSION="$(node -p 'require("./package.json").version')"
|
||||
git commit -m "chore(nix): refresh lock + npmDepsHash for v${VERSION}"
|
||||
git push origin "HEAD:${{ inputs.target_branch }}"
|
||||
- name: capture source commit sha
|
||||
id: final-sha
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "value=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
create-release:
|
||||
needs: prepare-nix-sources
|
||||
if: ${{ !cancelled() && !failure() && (needs.prepare-nix-sources.result == 'success' || needs.prepare-nix-sources.result == 'skipped') }}
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
release_id: ${{ steps.create-release.outputs.result }}
|
||||
package_version: ${{ steps.get-version.outputs.version }}
|
||||
release_tag: ${{ steps.tag.outputs.value }}
|
||||
source_commit_sha: ${{ steps.source-sha.outputs.value }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ inputs.verify_nix && needs.prepare-nix-sources.outputs.source_commit_sha || inputs.source_ref }}
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: lts/*
|
||||
cache: "npm"
|
||||
- name: get version
|
||||
id: get-version
|
||||
run: |
|
||||
set -euo pipefail
|
||||
V="$(node -p 'require("./package.json").version')"
|
||||
# Never publish with a missing/non-semver-ish version — empty becomes tag "app-v" on GitHub and breaks manifests.
|
||||
if [ -z "$V" ]; then
|
||||
echo "::error::package.json version is empty"
|
||||
exit 1
|
||||
fi
|
||||
if ! printf '%s' "$V" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+'; then
|
||||
echo "::error::package.json version must start with semver X.Y.Z... (got '$V')"
|
||||
exit 1
|
||||
fi
|
||||
delim="PKGVER_${GITHUB_RUN_ID}_${GITHUB_RUN_ATTEMPT}_$(openssl rand -hex 8)"
|
||||
{
|
||||
echo "version<<$delim"
|
||||
printf '%s\n' "$V"
|
||||
echo "$delim"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
- name: compute release tag
|
||||
id: tag
|
||||
env:
|
||||
VERSION: ${{ steps.get-version.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${VERSION:-}" ]; then
|
||||
echo "::error::release tag would be 'app-v' (empty version output) — aborting"
|
||||
exit 1
|
||||
fi
|
||||
TAG="app-v${VERSION}"
|
||||
if [ "$TAG" = "app-v" ]; then
|
||||
echo "::error::refuse to create invalid tag 'app-v'"
|
||||
exit 1
|
||||
fi
|
||||
echo "value=$TAG" >> "$GITHUB_OUTPUT"
|
||||
- name: capture source commit sha
|
||||
id: source-sha
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "value=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
- name: align existing tag to source ref
|
||||
env:
|
||||
TAG: ${{ steps.tag.outputs.value }}
|
||||
EXPECTED_SHA: ${{ steps.source-sha.outputs.value }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch --force --tags origin
|
||||
if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
|
||||
TAG_SHA="$(git rev-list -n 1 "refs/tags/$TAG")"
|
||||
if [ "$TAG_SHA" != "$EXPECTED_SHA" ]; then
|
||||
echo "::warning::Tag '$TAG' points to $TAG_SHA, expected $EXPECTED_SHA from source_ref '${{ inputs.source_ref }}'"
|
||||
echo "::warning::Re-pointing tag '$TAG' to expected source commit to keep Source code archives aligned with built artifacts."
|
||||
git tag -f "$TAG" "$EXPECTED_SHA"
|
||||
git push --force origin "refs/tags/$TAG"
|
||||
fi
|
||||
fi
|
||||
- name: extract changelog
|
||||
id: changelog
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${{ steps.get-version.outputs.version }}"
|
||||
BODY=""
|
||||
if node scripts/extract-release-section.mjs CHANGELOG.md "$VERSION" --allow-empty > /tmp/changelog-body.md; then
|
||||
BODY="$(cat /tmp/changelog-body.md)"
|
||||
fi
|
||||
EOF_MARKER=$(dd if=/dev/urandom bs=15 count=1 status=none | base64)
|
||||
echo "body<<$EOF_MARKER" >> "$GITHUB_OUTPUT"
|
||||
echo "$BODY" >> "$GITHUB_OUTPUT"
|
||||
echo "$EOF_MARKER" >> "$GITHUB_OUTPUT"
|
||||
- name: extract what's new for release asset
|
||||
id: whats-new
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${{ steps.get-version.outputs.version }}"
|
||||
CHANNEL="${{ inputs.channel }}"
|
||||
if ! node scripts/extract-release-section.mjs WHATS_NEW.md "$VERSION" > /tmp/whats-new.md; then
|
||||
if [ "$CHANNEL" = "release" ]; then
|
||||
echo "::error::WHATS_NEW.md has no section for version $VERSION (required for stable release)"
|
||||
exit 1
|
||||
fi
|
||||
echo "::warning::WHATS_NEW.md has no section for $VERSION — skipping whats-new.md asset"
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
- name: create or update release
|
||||
id: create-release
|
||||
uses: actions/github-script@v9
|
||||
env:
|
||||
PACKAGE_VERSION: ${{ steps.get-version.outputs.version }}
|
||||
RELEASE_TAG: ${{ steps.tag.outputs.value }}
|
||||
RELEASE_COMMIT_SHA: ${{ steps.source-sha.outputs.value }}
|
||||
CHANGELOG_BODY: ${{ steps.changelog.outputs.body }}
|
||||
IS_PRERELEASE: ${{ inputs.prerelease }}
|
||||
IS_DRAFT: ${{ inputs.draft_release }}
|
||||
with:
|
||||
script: |
|
||||
const tag = process.env.RELEASE_TAG;
|
||||
const body = process.env.CHANGELOG_BODY || "See the assets to download this version and install.";
|
||||
const prerelease = process.env.IS_PRERELEASE === "true";
|
||||
const draft = process.env.IS_DRAFT === "true";
|
||||
const version = process.env.PACKAGE_VERSION;
|
||||
const targetCommitish = process.env.RELEASE_COMMIT_SHA;
|
||||
const name = `Psysonic v${version}`;
|
||||
try {
|
||||
const { data } = await github.rest.repos.getReleaseByTag({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
tag,
|
||||
});
|
||||
await github.rest.repos.updateRelease({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
release_id: data.id,
|
||||
body,
|
||||
name,
|
||||
draft,
|
||||
prerelease,
|
||||
});
|
||||
return data.id;
|
||||
} catch (e) {
|
||||
if (e.status !== 404) throw e;
|
||||
}
|
||||
const { data } = await github.rest.repos.createRelease({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
tag_name: tag,
|
||||
target_commitish: targetCommitish,
|
||||
name,
|
||||
body,
|
||||
draft,
|
||||
prerelease,
|
||||
});
|
||||
return data.id;
|
||||
- name: upload whats-new.md release asset
|
||||
if: steps.whats-new.outputs.skip != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
RELEASE_TAG="${{ steps.tag.outputs.value }}"
|
||||
gh release upload "$RELEASE_TAG" /tmp/whats-new.md --clobber
|
||||
|
||||
build-macos-windows:
|
||||
if: ${{ inputs.build_platform_artifacts }}
|
||||
needs: create-release
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
settings:
|
||||
- platform: "macos-latest"
|
||||
args: "--target aarch64-apple-darwin"
|
||||
- platform: "macos-latest"
|
||||
args: "--target x86_64-apple-darwin"
|
||||
- platform: "windows-latest"
|
||||
args: "--bundles nsis"
|
||||
runs-on: ${{ matrix.settings.platform }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ needs.create-release.outputs.source_commit_sha }}
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: lts/*
|
||||
cache: "npm"
|
||||
- name: install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.settings.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
- name: cache cargo
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: src-tauri
|
||||
- name: install npm dependencies
|
||||
run: npm install
|
||||
- name: write Apple API key (macOS only)
|
||||
if: runner.os == 'macOS'
|
||||
run: |
|
||||
mkdir -p ~/private_keys
|
||||
echo "${{ secrets.APPLE_API_KEY_B64 }}" | base64 --decode > ~/private_keys/AuthKey.p8
|
||||
echo "APPLE_API_KEY_PATH=$HOME/private_keys/AuthKey.p8" >> "$GITHUB_ENV"
|
||||
- uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VITE_LASTFM_API_KEY: ${{ secrets.VITE_LASTFM_API_KEY }}
|
||||
VITE_LASTFM_API_SECRET: ${{ secrets.VITE_LASTFM_API_SECRET }}
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
with:
|
||||
releaseId: ${{ needs.create-release.outputs.release_id }}
|
||||
args: ${{ matrix.settings.args }}
|
||||
- name: re-sign updater bundle + upload .sig (macOS only)
|
||||
if: runner.os == 'macOS'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
run: |
|
||||
set -e
|
||||
RELEASE_TAG="${{ needs.create-release.outputs.release_tag }}"
|
||||
TARGET_ARG='${{ matrix.settings.args }}'
|
||||
if echo "$TARGET_ARG" | grep -q 'aarch64'; then
|
||||
TARGET="aarch64-apple-darwin"
|
||||
ARCH="aarch64"
|
||||
else
|
||||
TARGET="x86_64-apple-darwin"
|
||||
ARCH="x64"
|
||||
fi
|
||||
TARBALL="src-tauri/target/${TARGET}/release/bundle/macos/Psysonic.app.tar.gz"
|
||||
if [ ! -f "$TARBALL" ]; then
|
||||
echo "::error::Expected tarball missing: $TARBALL"
|
||||
ls -la "$(dirname "$TARBALL")" || true
|
||||
exit 1
|
||||
fi
|
||||
npx @tauri-apps/cli signer sign "$TARBALL"
|
||||
cp "${TARBALL}.sig" "Psysonic_${ARCH}.app.tar.gz.sig"
|
||||
gh release upload "$RELEASE_TAG" "Psysonic_${ARCH}.app.tar.gz.sig" --clobber
|
||||
|
||||
generate-manifest:
|
||||
if: ${{ inputs.build_platform_artifacts }}
|
||||
needs: [create-release, build-macos-windows]
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ needs.create-release.outputs.source_commit_sha }}
|
||||
- name: generate latest.json
|
||||
env:
|
||||
VERSION: ${{ needs.create-release.outputs.package_version }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: node scripts/generate-update-manifest.js
|
||||
- name: upload latest.json to release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
RELEASE_TAG="${{ needs.create-release.outputs.release_tag }}"
|
||||
gh release upload "$RELEASE_TAG" latest.json --clobber
|
||||
|
||||
build-linux:
|
||||
if: ${{ inputs.build_platform_artifacts }}
|
||||
needs: create-release
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ needs.create-release.outputs.source_commit_sha }}
|
||||
- name: install dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf \
|
||||
libasound2-dev squashfs-tools cmake
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: lts/*
|
||||
cache: "npm"
|
||||
- name: install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
- name: cache cargo
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: src-tauri
|
||||
- name: install npm dependencies
|
||||
run: npm install
|
||||
- name: build
|
||||
env:
|
||||
VITE_LASTFM_API_KEY: ${{ secrets.VITE_LASTFM_API_KEY }}
|
||||
VITE_LASTFM_API_SECRET: ${{ secrets.VITE_LASTFM_API_SECRET }}
|
||||
APPIMAGE_EXTRACT_AND_RUN: 1
|
||||
run: npm run tauri:build -- --bundles deb,rpm,appimage
|
||||
- name: upload Linux artifacts
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
RELEASE_TAG="${{ needs.create-release.outputs.release_tag }}"
|
||||
find src-tauri/target/release/bundle \
|
||||
\( -name "*.deb" -o -name "*.rpm" -o -name "*.AppImage" \) \
|
||||
| xargs gh release upload "$RELEASE_TAG" --clobber
|
||||
|
||||
bump-main-to-next-dev:
|
||||
if: ${{ inputs.channel == 'release' }}
|
||||
needs: create-release
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: main
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: lts/*
|
||||
- name: compute next dev version
|
||||
id: next-dev
|
||||
env:
|
||||
RELEASE_VERSION: ${{ needs.create-release.outputs.package_version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
NEXT_DEV="$(node -e 'const v=process.env.RELEASE_VERSION; const m=v.match(/^(\d+)\.(\d+)\.(\d+)/); if(!m){throw new Error(`Invalid release version: ${v}`)}; const major=Number(m[1]); const minor=Number(m[2]) + 1; process.stdout.write(`${major}.${minor}.0-dev`)')"
|
||||
echo "value=$NEXT_DEV" >> "$GITHUB_OUTPUT"
|
||||
- name: bump package version in main
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TARGET_VERSION="${{ steps.next-dev.outputs.value }}"
|
||||
CURRENT_VERSION="$(node -p 'require("./package.json").version')"
|
||||
SHOULD_BUMP="$(node -e '
|
||||
const parse = (v) => {
|
||||
const m = v.match(/^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/);
|
||||
if (!m) throw new Error(`Invalid semver: ${v}`);
|
||||
const pre = m[4] ?? "";
|
||||
const preRank = pre === "" ? 3 : pre.startsWith("rc.") ? 2 : pre === "dev" ? 1 : 0;
|
||||
return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]), preRank };
|
||||
};
|
||||
const a = parse(process.argv[1]); // current
|
||||
const b = parse(process.argv[2]); // target
|
||||
const keys = ["major", "minor", "patch", "preRank"];
|
||||
for (const k of keys) {
|
||||
if (b[k] > a[k]) { process.stdout.write("true"); process.exit(0); }
|
||||
if (b[k] < a[k]) { process.stdout.write("false"); process.exit(0); }
|
||||
}
|
||||
process.stdout.write("false");
|
||||
' "$CURRENT_VERSION" "$TARGET_VERSION")"
|
||||
if [[ "$SHOULD_BUMP" != "true" ]]; then
|
||||
echo "main already at ${CURRENT_VERSION} (target ${TARGET_VERSION} is not newer)"
|
||||
exit 0
|
||||
fi
|
||||
npm version --no-git-tag-version "$TARGET_VERSION"
|
||||
- name: sync Tauri version from package.json
|
||||
run: node scripts/sync-tauri-version-from-package.js
|
||||
- name: open PR with dev bump (if changed)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add package.json package-lock.json src-tauri/Cargo.toml src-tauri/Cargo.lock src-tauri/tauri.conf.json
|
||||
if git diff --cached --quiet; then
|
||||
echo "No dev version bump required."
|
||||
exit 0
|
||||
fi
|
||||
VERSION="${{ steps.next-dev.outputs.value }}"
|
||||
SAFE_VERSION="${VERSION//./-}"
|
||||
SAFE_VERSION="${SAFE_VERSION//\//-}"
|
||||
BRANCH="chore/version-bump-main-${SAFE_VERSION}"
|
||||
git checkout -b "$BRANCH"
|
||||
git commit -m "chore(release): bump main to ${VERSION}"
|
||||
git push origin "$BRANCH"
|
||||
gh pr create \
|
||||
--base main \
|
||||
--head "$BRANCH" \
|
||||
--title "chore(release): bump main to ${VERSION}" \
|
||||
--body "Auto-generated after stable release: updates \`package.json\`, \`package-lock.json\`, \`src-tauri/Cargo.toml\`, \`src-tauri/Cargo.lock\`, and \`src-tauri/tauri.conf.json\` to the next development version."
|
||||
@@ -1,92 +0,0 @@
|
||||
name: rust-tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'src-tauri/**'
|
||||
- '.github/workflows/rust-tests.yml'
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'src-tauri/**'
|
||||
- '.github/workflows/rust-tests.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: cargo test --workspace
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: install Linux build dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev \
|
||||
libasound2-dev cmake
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: src-tauri
|
||||
- name: cargo test --workspace
|
||||
working-directory: src-tauri
|
||||
run: cargo test --workspace --all-targets
|
||||
|
||||
clippy:
|
||||
name: cargo clippy --workspace
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: install Linux build dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev \
|
||||
libasound2-dev cmake
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: src-tauri
|
||||
- name: cargo clippy --workspace
|
||||
working-directory: src-tauri
|
||||
run: cargo clippy --workspace --all-targets -- -D warnings
|
||||
|
||||
coverage:
|
||||
name: cargo llvm-cov (baseline + hot-path file gate)
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: install Linux build dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev \
|
||||
libasound2-dev cmake jq
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: llvm-tools-preview
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: src-tauri
|
||||
- uses: taiki-e/install-action@v2
|
||||
with:
|
||||
tool: cargo-llvm-cov
|
||||
- name: generate workspace coverage (lcov + json)
|
||||
working-directory: src-tauri
|
||||
run: |
|
||||
mkdir -p target/llvm-cov
|
||||
cargo llvm-cov --workspace --lcov --output-path lcov.info
|
||||
cargo llvm-cov --workspace --json --output-path target/llvm-cov/cov.json
|
||||
- name: hot-path file coverage gate
|
||||
run: bash scripts/check-hot-path-coverage.sh
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: rust-coverage-lcov
|
||||
path: src-tauri/lcov.info
|
||||
if-no-files-found: error
|
||||
@@ -1,116 +0,0 @@
|
||||
name: Validate Main Green CI
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
branch:
|
||||
description: "Branch to validate"
|
||||
required: false
|
||||
default: main
|
||||
type: string
|
||||
required_contexts:
|
||||
description: "Comma-separated required checks; use | for alternatives (e.g. ci-ok|ci-main / ci-ok)"
|
||||
required: false
|
||||
default: ci-ok|ci-main / ci-ok
|
||||
type: string
|
||||
workflow_call:
|
||||
inputs:
|
||||
branch:
|
||||
required: false
|
||||
default: main
|
||||
type: string
|
||||
required_contexts:
|
||||
required: false
|
||||
default: ci-ok|ci-main / ci-ok
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
checks: read
|
||||
steps:
|
||||
- name: validate required checks
|
||||
uses: actions/github-script@v9
|
||||
env:
|
||||
TARGET_BRANCH: ${{ inputs.branch || github.event.inputs.branch || 'main' }}
|
||||
REQUIRED_CONTEXTS: ${{ inputs.required_contexts || github.event.inputs.required_contexts || 'ci-ok|ci-main / ci-ok' }}
|
||||
with:
|
||||
script: |
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const branch = process.env.TARGET_BRANCH || "main";
|
||||
const groups = String(process.env.REQUIRED_CONTEXTS || "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.map((group) =>
|
||||
group
|
||||
.split("|")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
);
|
||||
|
||||
const runId = String(context.runId);
|
||||
const branchResp = await github.rest.repos.getBranch({ owner, repo, branch });
|
||||
const sha = branchResp.data.commit.sha;
|
||||
core.info(`Validating checks for ${branch} @ ${sha}`);
|
||||
|
||||
const checksAll = await github.paginate(github.rest.checks.listForRef, {
|
||||
owner,
|
||||
repo,
|
||||
ref: sha,
|
||||
per_page: 100,
|
||||
});
|
||||
const checks = checksAll.filter((c) => !(c.details_url || "").includes(`/actions/runs/${runId}/`));
|
||||
|
||||
const newestByName = new Map();
|
||||
for (const c of checks) {
|
||||
const key = c.name;
|
||||
const prev = newestByName.get(key);
|
||||
if (!prev) {
|
||||
newestByName.set(key, c);
|
||||
continue;
|
||||
}
|
||||
const prevTime = Date.parse(prev.started_at || prev.completed_at || "") || 0;
|
||||
const curTime = Date.parse(c.started_at || c.completed_at || "") || 0;
|
||||
if (curTime >= prevTime) {
|
||||
newestByName.set(key, c);
|
||||
}
|
||||
}
|
||||
|
||||
const failures = [];
|
||||
for (const alternatives of groups) {
|
||||
let ok = false;
|
||||
const altNotes = [];
|
||||
for (const name of alternatives) {
|
||||
const latest = newestByName.get(name);
|
||||
if (!latest) {
|
||||
altNotes.push(`${name}: missing`);
|
||||
continue;
|
||||
}
|
||||
if (latest.status !== "completed") {
|
||||
altNotes.push(`${name}: status=${latest.status}, conclusion=${latest.conclusion}`);
|
||||
continue;
|
||||
}
|
||||
if (["success", "neutral", "skipped"].includes(latest.conclusion || "")) {
|
||||
ok = true;
|
||||
break;
|
||||
}
|
||||
altNotes.push(`${name}: conclusion=${latest.conclusion}`);
|
||||
}
|
||||
if (!ok) {
|
||||
failures.push(`(${alternatives.join(" | ")}): none green — ${altNotes.join("; ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
core.setFailed(`Required checks for ${branch} are not green:\n${failures.join("\n")}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const summary = groups
|
||||
.map((alts) => (alts.length > 1 ? `(${alts.join(" | ")})` : alts[0]))
|
||||
.join(", ");
|
||||
core.info(`All required checks for ${branch} are green (${summary}).`);
|
||||
@@ -1,21 +0,0 @@
|
||||
name: Publish to WinGet
|
||||
on:
|
||||
release:
|
||||
types: [released]
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Derive winget version from app-v* tag
|
||||
id: ver
|
||||
env:
|
||||
TAG: ${{ github.event.release.tag_name }}
|
||||
run: echo "value=${TAG#app-v}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Submit to WinGet Community Repository
|
||||
uses: vedantmgoyal9/winget-releaser@v2
|
||||
with:
|
||||
identifier: Psychotoxical.Psysonic
|
||||
version: ${{ steps.ver.outputs.value }}
|
||||
installers-regex: 'Psysonic_.*_x64-setup\.exe$'
|
||||
token: ${{ secrets.WINGET_TOKEN }}
|
||||
@@ -7,11 +7,6 @@ yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Environment variables (API keys)
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Node
|
||||
node_modules
|
||||
dist
|
||||
@@ -31,43 +26,3 @@ dist-ssr
|
||||
|
||||
# Tauri
|
||||
src-tauri/target/
|
||||
src-tauri/gen/
|
||||
src-tauri/lcov.info
|
||||
|
||||
# Frontend test coverage
|
||||
coverage/
|
||||
|
||||
# Generated at build/test/dev time (scripts/generate-release-notes-bundle.mjs).
|
||||
# Ignore the contents (not the dir) so the committed tauri-specta FE↔BE contract
|
||||
# snapshot below can be re-included — a `!` exception cannot escape an ignored dir.
|
||||
src/generated/*
|
||||
# ...except the committed tauri-specta contract snapshot, so CI diffs catch drift.
|
||||
!src/generated/bindings.ts
|
||||
|
||||
# Documentation
|
||||
CLAUDE.md
|
||||
|
||||
# Local commit-instructions for agents (never commit)
|
||||
to_commit.md
|
||||
|
||||
# Claude Code memory (local only)
|
||||
memory/
|
||||
|
||||
# Local scratchpad / notes (not committed)
|
||||
tmp/
|
||||
|
||||
# Third-party clones for local research (not committed)
|
||||
research/
|
||||
|
||||
# Nix build output symlink
|
||||
result
|
||||
result-*
|
||||
|
||||
# Local incremental cargo (nix develop; not used by flake — see nix/psysonic.nix cleanSource)
|
||||
.build-local/
|
||||
|
||||
# Local Nix helpers (not in repo — optional; use `nix develop` / flake without them)
|
||||
dev.sh
|
||||
shell.nix
|
||||
prod.sh
|
||||
tsconfig.tsbuildinfo
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
# Contributing to Psysonic
|
||||
|
||||
Thanks for your interest in helping the project.
|
||||
|
||||
Psysonic is **GPLv3** — see [LICENSE](LICENSE). Forks and modifications are welcome under the license; for attribution expectations when publishing derivative work, see **Forks and Attribution** in the [README](README.md).
|
||||
|
||||
## Contents
|
||||
|
||||
- [Quick start](#quick-start)
|
||||
- [Before you write code](#before-you-write-code)
|
||||
- [Repository layout](#repository-layout)
|
||||
- [Environment and running the app](#environment-and-running-the-app)
|
||||
- [Where processes and conventions are documented](#where-processes-and-conventions-are-documented)
|
||||
- [House rules](#house-rules)
|
||||
- [The Rust ↔ frontend (Tauri) contract](#the-rust--frontend-tauri-contract)
|
||||
- [CI on pull requests to `main`](#ci-on-pull-requests-to-main)
|
||||
- [Local checks](#local-checks)
|
||||
- [Pull request expectations](#pull-request-expectations)
|
||||
- [Why we are wary of irreversible UI churn](#why-we-are-wary-of-irreversible-ui-churn)
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Psychotoxical/psysonic.git
|
||||
cd psysonic
|
||||
npm install
|
||||
npm run tauri:dev # run the desktop app in dev mode
|
||||
npm test # frontend tests (Vitest)
|
||||
( cd src-tauri && cargo test --workspace --all-targets ) # backend tests
|
||||
```
|
||||
|
||||
Open pull requests against `main`. `next` and `release` are maintainer-driven promotion branches — don't target them directly. The rest of this document covers what reviewers look for, especially around the [Tauri contract](#the-rust--frontend-tauri-contract) and UI changes.
|
||||
|
||||
---
|
||||
|
||||
## Before you write code
|
||||
|
||||
- **Usage questions** ("is this a bug or my setup?") — please use [Discord](https://discord.gg/AMnDRErm4u) or [Telegram](https://t.me/+GLBx1_xeH28xYTJi) first. The issue tracker is intended for confirmed bugs and feature requests (see [issue templates](.github/ISSUE_TEMPLATE/)).
|
||||
- **AUR packaging problems** — follow the AUR links in [README](README.md); those packages are maintained separately from this repository.
|
||||
- **Large features or UX overhauls** — consider discussing in chat or opening an issue early so effort aligns with product direction.
|
||||
- **Changes to the Tauri boundary** — read [The Rust ↔ frontend (Tauri) contract](#the-rust--frontend-tauri-contract) before opening a PR; reviewers will ask for a clear justification.
|
||||
- **Security issues** — please do **not** open a public issue. See [SECURITY.md](SECURITY.md) for how to report vulnerabilities privately (Discord or Telegram).
|
||||
|
||||
---
|
||||
|
||||
## Repository layout
|
||||
|
||||
```text
|
||||
src/ React / TypeScript frontend
|
||||
src-tauri/ Rust backend (Tauri host process)
|
||||
public/ Static assets served by Vite
|
||||
scripts/ CI helpers (coverage gates, install, version sync)
|
||||
.github/ Workflows, issue templates, hot-path lists
|
||||
flake.nix Nix development shell + packaging
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Environment and running the app
|
||||
|
||||
See [README](README.md) (**Development**) for the basic flow: from the repository root, `npm install` then `npm run tauri:dev` for development or `npm run tauri:build` for a release build. Use `npm install` while iterating; `npm ci` is what CI runs and is the right command when you want a reproducible install.
|
||||
|
||||
For non-Linux contributors, install the native dependencies Tauri requires on your OS — see the upstream [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/) (Windows: WebView2 + MSVC build tools; macOS: Xcode Command Line Tools). The Linux package list used in CI is in [`rust-tests.yml`](.github/workflows/rust-tests.yml).
|
||||
|
||||
If you use **Nix**, `nix develop` (see [`flake.nix`](flake.nix)) provides the pinned toolchain and native dependencies. Adding `psysonic.cachix.org` as a substituter (badge in the [README](README.md)) lets you pull prebuilt dev-shell dependencies instead of rebuilding them locally.
|
||||
|
||||
---
|
||||
|
||||
## Where processes and conventions are documented
|
||||
|
||||
| Topic | Location |
|
||||
|--------|----------|
|
||||
| Frontend test stack (Vitest, Tauri/Subsonic mocks, store resets, i18n in tests) | [`src/test/README.md`](src/test/README.md) |
|
||||
| What CI runs for frontend / backend | [`frontend-tests.yml`](.github/workflows/frontend-tests.yml), [`eslint.yml`](.github/workflows/eslint.yml), [`rust-tests.yml`](.github/workflows/rust-tests.yml) |
|
||||
| Frontend "hot path" files held to a coverage threshold | [`frontend-hot-path-files.txt`](.github/frontend-hot-path-files.txt), [`check-frontend-hot-path-coverage.sh`](scripts/check-frontend-hot-path-coverage.sh) |
|
||||
| Rust hot-path gate | [`hot-path-files.txt`](.github/hot-path-files.txt), [`check-hot-path-coverage.sh`](scripts/check-hot-path-coverage.sh) |
|
||||
| Nix packaging / release automation | [`flake.nix`](flake.nix), workflows under [`.github/workflows/`](.github/workflows/) |
|
||||
|
||||
---
|
||||
|
||||
## House rules
|
||||
|
||||
1. **One pull request, one coherent goal.** Easier review, easier revert, fewer merge conflicts.
|
||||
2. **Match existing style** in touched files (naming, module layout, comment density). Avoid drive-by refactors unrelated to the task.
|
||||
3. **Linting and formatting:** ESLint (strict `eslint.config.mjs`) and **`npm run dep:check`** (dependency-cruiser layering/cycle guard) run in CI on frontend paths; run both locally before opening a frontend PR. `tsc --noEmit` is also required. For Rust, `cargo clippy --workspace --all-targets -- -D warnings` is the lint gate; `cargo fmt` is not currently required but won't hurt.
|
||||
4. **Commit messages:** a short **human-readable** summary of what changed and why; Conventional Commits-style prefixes (`feat:`, `fix:`, ...) are fine if you prefer them. Do not include meta references (IDEs, assistants, or how the message was produced) — only what matters for project history.
|
||||
5. **License:** new code must remain compatible with the project's GPLv3.
|
||||
6. **Tests:** when you change behaviour users rely on, add or update tests next to the code (see [`src/test/README.md`](src/test/README.md)). Purely visual tweaks may not need tests, but behavioural regressions should be covered where the suite can catch them.
|
||||
7. **i18n:** user-visible strings live in `src/locales/*.ts` (one TypeScript module per language) and are wired up in `src/i18n.ts`. English (`en.ts`) is the baseline — always add the key there. Other locales may be left for follow-up translation PRs if you don't speak the language, but keep the object shape consistent so missing keys are obvious.
|
||||
- **Adding a new language (not just keys):** the Rust cluster-key normalizer (`src-tauri/crates/psysonic-library/src/identity/norm.rs`) folds diacritics/ligatures per shipped locale so library items match across servers. When a locale introduces a script or letters it does not yet cover, extend its decomposition table for that language and **bump `NORM_VERSION`** (existing `library-cluster.db` keys rebuild automatically). CJK locales are intentionally left verbatim. The module header carries the same checklist.
|
||||
|
||||
---
|
||||
|
||||
## The Rust ↔ frontend (Tauri) contract
|
||||
|
||||
Treat `invoke` handlers, event names, and JSON/payload shapes as a **public API between two codebases**. Prefer **additive** changes (new optional fields, new commands/events) over silent renames or breaking shape changes.
|
||||
|
||||
When a breaking change is unavoidable, it should be:
|
||||
|
||||
- **narrow** and **documented in the PR**,
|
||||
- paired with updates on **both sides** of the boundary, and
|
||||
- paired with updates to any Vitest Tauri mocks that encode the contract.
|
||||
|
||||
Drive-by churn here is expensive: it hurts forks, complicates bisects, and forces every contributor to relearn the boundary. If the same outcome can be achieved inside Rust or inside React alone, default to that.
|
||||
|
||||
Align early: open an issue or chat thread before sending a PR that renames `invoke` commands, changes event payloads, or reshapes data across the boundary. Reviewers will ask for a clear benefit because every such change ripples through `src-tauri`, `src`, tests, and future contributors' mental model.
|
||||
|
||||
---
|
||||
|
||||
## Frontend architecture and layering
|
||||
|
||||
The frontend uses a feature-folder architecture (introduced in #1225) with a layering contract that CI enforces through **`npm run dep:check`** (dependency-cruiser). The rule is simple: **a lower layer must never import a higher one.**
|
||||
|
||||
```
|
||||
lib → store / ui → cover / music-network → features/<x> → app
|
||||
```
|
||||
|
||||
- **`lib/**` is the floor** — feature-free infrastructure (API clients, formatting, i18n, util, media, server, navigation). It must **not** import from `store`, `ui`, `features`, `cover`, `music-network`, or `app`. If a `lib` helper needs auth/server state, pass it in as an argument or keep the helper in the layer that owns that state (`store` or `features`) — do not reach into a store from `lib`.
|
||||
- **`store` / `ui`** may import `lib` only.
|
||||
- **`cover` / `music-network`** are top-level domains and may import `lib`, `store`, `ui`.
|
||||
- **`features/<x>`** may import `lib`, `store`, `ui`, `cover`, `music-network`, and other features — but cross-feature access goes **only** through the `@/features/<x>` barrel, never a deep path.
|
||||
- **`app`** (shell + bridges) may import anything.
|
||||
- **No import cycles** anywhere under `src/`.
|
||||
|
||||
The authoritative rules and rationale live in [`.dependency-cruiser.cjs`](.dependency-cruiser.cjs) (its header documents the layers and the known-violation ratchet). Any **new** layering or cycle violation fails the `dependency-cruiser` job and blocks `ci-ok`, so run **`npm run dep:check`** locally before opening a frontend PR. The known-violations baseline in `.dependency-cruiser-known-violations.json` only ratchets **down** — don't regenerate it to silence a new violation; fix the import instead.
|
||||
|
||||
---
|
||||
|
||||
## CI on pull requests to `main`
|
||||
|
||||
PRs must target `main`. `next` and `release` are maintainer-driven promotion branches — don't target them directly.
|
||||
|
||||
Workflows are path-filtered (see the YAML for exact `paths` / `paths-ignore`):
|
||||
|
||||
- **Frontend** (`src/**`, lockfile, Vitest/Vite/tsconfig, ESLint config, dependency-cruiser config, etc.): `npm run lint`, **`npm run dep:check`** (layering + cycle guard), `npm test` (Vitest), `npx tsc --noEmit`, then a coverage run.
|
||||
- **Rust** (`src-tauri/**`): `cargo test --workspace --all-targets`, `cargo clippy --workspace --all-targets -- -D warnings`, then coverage.
|
||||
|
||||
The **`ci-ok`** job in [`ci-main.yml`](.github/workflows/ci-main.yml) is the merge gate: it waits for every required job above whose path filter matched the PR, and fails if any of them failed or did not finish in time.
|
||||
|
||||
Hot-path coverage gates are **required** on pull requests: the `coverage` jobs in [`frontend-tests.yml`](.github/workflows/frontend-tests.yml) and [`rust-tests.yml`](.github/workflows/rust-tests.yml) fail when any listed file drops below the floor. See the headers in [`frontend-hot-path-files.txt`](.github/frontend-hot-path-files.txt) and [`hot-path-files.txt`](.github/hot-path-files.txt) for curation rules and thresholds.
|
||||
|
||||
---
|
||||
|
||||
## Local checks
|
||||
|
||||
Assume the repository root is `psysonic/` (for example after `git clone https://github.com/Psychotoxical/psysonic.git` and `cd psysonic`).
|
||||
|
||||
**Frontend** — from the repository root:
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
npm run lint
|
||||
npm run dep:check
|
||||
npm test
|
||||
npm run prebuild:release-notes
|
||||
npx tsc --noEmit
|
||||
npm run test:coverage
|
||||
bash scripts/check-frontend-hot-path-coverage.sh
|
||||
```
|
||||
|
||||
The last command mirrors the optional hot-path gate used in CI; `jq` must be on `PATH`.
|
||||
|
||||
**Rust** — install the Linux packages your distro needs to build Tauri/WebKitGTK (the list used in Ubuntu CI is in [`rust-tests.yml`](.github/workflows/rust-tests.yml) under `apt-get install`), or use `nix develop`. Then:
|
||||
|
||||
```bash
|
||||
cd src-tauri
|
||||
cargo test --workspace --all-targets
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
```
|
||||
|
||||
To reproduce the **coverage + hot-path** job locally you also need:
|
||||
|
||||
- `cargo-llvm-cov`
|
||||
- the `llvm-tools-preview` rustup component
|
||||
- `jq` on `PATH`
|
||||
|
||||
The exact `cargo llvm-cov` invocations and the gate call are taken from the `coverage` job in [`rust-tests.yml`](.github/workflows/rust-tests.yml). After generating `src-tauri/target/llvm-cov/cov.json` as that job does, run the gate from the **repository root**:
|
||||
|
||||
```bash
|
||||
bash scripts/check-hot-path-coverage.sh
|
||||
```
|
||||
|
||||
If you change both frontend and backend, run the relevant blocks above before opening a PR.
|
||||
|
||||
---
|
||||
|
||||
## Pull request expectations
|
||||
|
||||
- **Description:** what changed, who should notice (end users vs developers only), how to verify manually. Link the issue if the PR closes it.
|
||||
- **Scope:** stay on task; no unrelated reformatting or cleanup in the same PR.
|
||||
- **UI/UX:** describe the user flow; before/after screenshots help reviewers a lot.
|
||||
- **i18n:** see [House rules](#house-rules) — add the key to `en.ts` first, keep the shape of other locales consistent.
|
||||
- **Server compatibility:** the client targets the Subsonic API and is **Navidrome-first**; if a feature depends on server support, say so explicitly.
|
||||
- **Tauri boundary:** if you touched it, list added/removed/renamed commands and events, describe payload changes, and note how you verified both `src-tauri` and `src` (plus any updated tests/mocks). If you did **not** touch the boundary, saying so helps reviewers scope the review.
|
||||
- **Persisted settings / on-disk layout:** if you change how configuration or local data is stored, migrated, or located, spell out the impact on **existing installs** (one-time migration, backwards compatibility, or explicit break with rationale).
|
||||
|
||||
---
|
||||
|
||||
## Why we are wary of irreversible UI churn
|
||||
|
||||
Psysonic is a desktop app people use for hours: muscle memory, layout, themes, keyboard workflows, and accessibility settings all matter. Abrupt changes to navigation, information hierarchy, or visual language without a migration path:
|
||||
|
||||
- break **habits and power-user flows**;
|
||||
- complicate **themes and accessibility** (contrast, sizing, custom fonts);
|
||||
- increase support load and frustration — some users stay on old builds or fork.
|
||||
|
||||
We prefer **evolutionary** UI work: discuss large shifts early, ship in steps where possible, use settings or toggles when a breaking visual change is justified, and preserve predictability where users did not ask for an experiment. That is not a ban on fresh design — it is a preference to **not strand users** without a strong reason and a clear adaptation path.
|
||||
@@ -1,20 +1,21 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
MIT License
|
||||
|
||||
Copyright (C) 2026 Psychotoxical
|
||||
Copyright (c) 2026 Psychotoxical
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
The full license text is available at:
|
||||
https://www.gnu.org/licenses/gpl-3.0.txt
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
# Notice
|
||||
|
||||
Psysonic is licensed under the GNU GPLv3.
|
||||
|
||||
Psysonic, including features such as **Orbit** (synchronized shared
|
||||
listening sessions), was originally designed and implemented by the
|
||||
Psysonic project maintainers and contributors.
|
||||
|
||||
Forks and modified versions are welcome under the GPLv3, but they
|
||||
must preserve copyright notices, license information, and clear
|
||||
attribution to the original Psysonic project.
|
||||
|
||||
Modified versions must not imply that original Psysonic features were
|
||||
independently created by the fork, nor imply endorsement by the
|
||||
Psysonic project.
|
||||
|
||||
## Required Attribution for Derivative Works
|
||||
|
||||
The following attribution requirements apply to all derivative works
|
||||
under the additional terms permitted by **GPLv3 §7(b)**. Forks and
|
||||
redistributions must preserve these notices intact:
|
||||
|
||||
1. The unmodified contents of this `NOTICE.md` file must be retained
|
||||
in the source tree of any fork or derivative work.
|
||||
2. Derivative works that include or are based on the **Orbit**
|
||||
feature — its design, protocol, or implementation — must clearly
|
||||
state in their own documentation and release notes that the Orbit
|
||||
feature was originally designed and implemented in Psysonic, and
|
||||
must not present Orbit's design or implementation as independent
|
||||
original work of the fork.
|
||||
3. The user-facing name "Orbit" is a Psysonic feature mark and may
|
||||
not be reused by forks; see `TRADEMARK.md`. The attribution
|
||||
requirement in (2) applies regardless of what the feature is
|
||||
renamed to in a fork.
|
||||
4. The same applies to any other original Psysonic feature that a
|
||||
fork chooses to retain.
|
||||
|
||||
These terms do not restrict the freedoms granted by the GPLv3; they
|
||||
are attribution-preservation terms explicitly permitted by GPLv3
|
||||
§7(b) and must be carried forward in further redistribution.
|
||||
@@ -1,344 +0,0 @@
|
||||
# Psy Orbit
|
||||
|
||||
A "listen together" mode built into Psysonic. One participant hosts the music, others tune in and listen in sync. Guests can suggest tracks; the host decides what lands in the queue.
|
||||
|
||||
No external servers, no relays, no accounts on yet another platform — Orbit piggybacks entirely on your existing Navidrome instance. Sessions live in regular playlists (with a small JSON blob in the comment field), the clients poll and write to those playlists, and that's it.
|
||||
|
||||
---
|
||||
|
||||
## Table of contents
|
||||
|
||||
- [For users](#for-users)
|
||||
- [Starting a session (host)](#starting-a-session-host)
|
||||
- [Joining (guest)](#joining-guest)
|
||||
- [Suggesting tracks](#suggesting-tracks)
|
||||
- [Approvals](#approvals)
|
||||
- [Shared queue](#shared-queue)
|
||||
- [Session settings](#session-settings)
|
||||
- [Participants](#participants)
|
||||
- [Ending the session](#ending-the-session)
|
||||
- [Requirements & limits](#requirements--limits)
|
||||
- [How it works (technical)](#how-it-works-technical)
|
||||
- [Design goals](#design-goals)
|
||||
- [Playlists as transport](#playlists-as-transport)
|
||||
- [The host tick](#the-host-tick)
|
||||
- [The guest tick](#the-guest-tick)
|
||||
- [Data flow](#data-flow)
|
||||
- [State shape](#state-shape)
|
||||
- [Cleanup](#cleanup)
|
||||
- [Security & privacy](#security--privacy)
|
||||
- [Edge cases handled](#edge-cases-handled)
|
||||
- [Code map](#code-map)
|
||||
|
||||
---
|
||||
|
||||
## For users
|
||||
|
||||
### Starting a session (host)
|
||||
|
||||
Click **Psy Orbit** in the top bar → **Create a session**. The start modal opens with:
|
||||
|
||||
- **Session name** — a random playful name is generated; edit it or reroll with the dice button.
|
||||
- **Max guests** — cap on concurrent participants (1–32). You don't count.
|
||||
- **Session server** — when your library scope includes several servers, choose the Navidrome instance that will host the session. Psysonic temporarily narrows the library scope and mixed queue to that server, then restores the previous scope when the session ends.
|
||||
- **Invite link** — ready to copy and share the moment the modal opens. Pre-generated from a fresh session id + the slugified name.
|
||||
- **Clear my queue first** — optional. Start with an empty queue (guest suggestions land fresh) vs. keep the chosen server's queued tracks and share them with the guests.
|
||||
|
||||
Click **Start Orbit**. The session bar appears at the top of the window (session name, participant count, shuffle countdown, settings / share / help / exit buttons). The link is now live — share it.
|
||||
|
||||
### Joining (guest)
|
||||
|
||||
Two equivalent paths:
|
||||
|
||||
1. **Paste anywhere** — copy the invite link the host sent you. Anywhere in Psysonic (not inside a text field), press `Ctrl+V` (`Cmd+V` on macOS). A confirm dialog shows who invited you; click Join.
|
||||
2. **Launch popover** — click **Psy Orbit** in the top bar → **Join a session** → paste the link into the field → Join.
|
||||
|
||||
Either path performs the same preflight: validates the link, checks the session still exists, handles server switches automatically if the link points at another Navidrome you have an account for. If you have **multiple accounts** on the target server, a small picker asks which one to join as.
|
||||
|
||||
### Suggesting tracks
|
||||
|
||||
Anywhere a song row appears (album, playlist, favorites, artist top-songs, search results, random mix, advanced search):
|
||||
|
||||
- **Double-click** a row → adds just that track.
|
||||
- **Right-click → "Add to Orbit session"** — same effect, via context menu.
|
||||
- **Single-click** on a row shows a toast hint: "Double-click to add". This is deliberate — a single click normally drops the whole album into your queue, which would spam the shared queue and annoy everyone.
|
||||
|
||||
Explicit bulk buttons (**Play All** / **Play Album** / **Play Playlist** / Hero play / Album-card play) ask for a confirmation first inside an active session. On confirm, the tracks are **appended** to the shared queue, never replacing it.
|
||||
|
||||
### Approvals
|
||||
|
||||
By default, a new session starts with **auto-approve off**. Guest suggestions land in the session's suggestion history but not the actual playback queue — the host decides.
|
||||
|
||||
The host sees a prominent **Pending approvals** strip at the top of the queue panel: each pending track with cover, title, artist, and "Suggested by …" line, plus two buttons:
|
||||
|
||||
- ✓ Accept — enqueues the track into the host's player queue. Guests see it appear in the shared queue on the next tick.
|
||||
- ✕ Decline — drops the suggestion. It stays in the suggestion history for audit but won't show up again in the approval strip.
|
||||
|
||||
Auto-approve can be toggled on in the session settings for any session where manual approval isn't needed.
|
||||
|
||||
### Shared queue
|
||||
|
||||
Both hosts and guests see a strip at the top of the queue panel with the session name and a comma-separated list of all participants (host first). Under that:
|
||||
|
||||
- **Host** view: regular queue, with any new guest suggestions injected into random positions inside the upcoming range.
|
||||
- **Guest** view: read-only display of the host's upcoming queue (up to 30 tracks at a time) with submitter attribution — "by alice" for host-chosen tracks is omitted; "suggested by alice" is shown for guest suggestions.
|
||||
|
||||
When the guest has in-flight suggestions that haven't been merged yet, they appear in a separate **Waiting for host** section above Up next, with a clock icon. Once the host (auto-)approves and merges, they move into the normal list.
|
||||
|
||||
### Session settings
|
||||
|
||||
Open via the gear icon in the session bar (host only):
|
||||
|
||||
- **Auto-approve suggestions** — on/off. Default off.
|
||||
- **Automatic reshuffle** — on/off. Periodically Fisher–Yates-shuffles the upcoming queue.
|
||||
- **Reshuffle every** — 1 / 5 / 10 / 15 / 30 min preset picker. Disabled when auto-reshuffle is off.
|
||||
- **Shuffle now** — one-shot manual shuffle + bumps the next-auto-shuffle timer.
|
||||
|
||||
### Participants
|
||||
|
||||
Click the participant count in the session bar. Opens a popover:
|
||||
|
||||
- Host row at the top with a crown icon.
|
||||
- Each connected guest with a user icon, username, and join timestamp.
|
||||
- Host-only actions per guest: **Remove** (drops them from the session, can re-join via the invite link) vs. **Ban** (permanently blocked for the lifetime of the session). Both confirm before firing.
|
||||
|
||||
Guests see the same list but read-only — no action buttons.
|
||||
|
||||
### Ending the session
|
||||
|
||||
- **Host clicks X** → confirm dialog → session closes for everyone. Server playlists are deleted automatically.
|
||||
- **Guest clicks X** → confirm dialog → just the guest leaves. Session continues for everyone else.
|
||||
- **Host goes silent for 5 minutes** (network drop, app crash, laptop lid) → guests auto-leave with a dedicated "Host went silent" modal.
|
||||
- **App close / force quit** → next app launch sweeps up any orphaned session playlists you own (`__psyorbit_*` with stale heartbeat).
|
||||
|
||||
### The help modal
|
||||
|
||||
Every screen with the session bar has a `?` icon between settings and X that opens a 9-section walk-through of everything above, with keyboard navigation (arrow keys between sections, Enter to expand).
|
||||
|
||||
---
|
||||
|
||||
## Requirements & limits
|
||||
|
||||
- **Same Navidrome server.** Everyone — host and all guests — must be logged into the same Navidrome instance. Orbit links encode the server URL, and Psysonic auto-switches on paste if you have an account there.
|
||||
- **Separate accounts.** Each participant needs their own Navidrome user. If a host and guest log in as the same user, their outbox playlists collide and suggestions get lost. This is a hard limit of the current design — Orbit identifies participants by username.
|
||||
- **Public server address for remote guests.** Guests outside your home network need your server reachable at a public hostname. The start modal warns you if you're currently connected via a LAN address.
|
||||
- **Host presence matters.** Guests auto-leave after 5 minutes of no host activity. Shorter reconnects (network blips, phone screen off, whatever) are invisible.
|
||||
- **Session size.** State is bounded to ~4 KB per playlist comment. In practice that's plenty for a session name, participants list, and a suggestion history; there's no hard cap on tracks through the actual playback queue.
|
||||
|
||||
---
|
||||
|
||||
## How it works (technical)
|
||||
|
||||
### Design goals
|
||||
|
||||
1. **No external infrastructure.** Everything runs on your Navidrome. No relay, no auth server, no persistent state anywhere you don't already own.
|
||||
2. **No protocol changes.** Uses Navidrome's existing Subsonic/OpenSubsonic playlist endpoints. If your server can host a normal playlist, it can host an Orbit session.
|
||||
3. **Degrade gracefully.** A dropped tick doesn't break a session. Network blips are silent. Missing heartbeats expire cleanly. Crashes clean up on the next launch.
|
||||
4. **Host-authoritative.** The host's player is the ground truth; guests mirror. No distributed consensus, no leader election.
|
||||
|
||||
### Playlists as transport
|
||||
|
||||
Every session creates two kinds of playlists on the server (names are stable and start with `__psyorbit_`):
|
||||
|
||||
| Playlist | Who owns it | What's in it |
|
||||
|---|---|---|
|
||||
| `__psyorbit_<sid>` | host | Canonical session state (4 KB JSON blob) in the playlist **comment**. Track list is always empty. |
|
||||
| `__psyorbit_<sid>_from_<user>__` | each participant | Outbox. Comment holds a heartbeat timestamp; the track list holds pending guest suggestions. |
|
||||
|
||||
All playlists are marked `public: true` so every participant can read them via the normal Subsonic endpoints (`getPlaylist.view`, `getPlaylists.view`). Psysonic filters `__psyorbit_*` out of its own UI (Playlists page, pickers, context menu), but the Navidrome web client will show them while a session is active.
|
||||
|
||||
### The host tick
|
||||
|
||||
Fired every 2.5 s from `useOrbitHost`:
|
||||
|
||||
1. **Sweep all outboxes.** List every `__psyorbit_<sid>_from_<user>__` playlist. For each one, read the current tracklist (= new suggestions from that guest) and the heartbeat timestamp from the comment.
|
||||
2. **Apply snapshots to state.** Rebuild the `participants` array from heartbeat freshness (anyone with a heartbeat < 30 s old is "alive"). Append new suggestions to `state.queue` as `OrbitQueueItem { trackId, addedBy, addedAt }`.
|
||||
3. **Clear each swept outbox's tracklist** (heartbeat stays). Single-pass consume — a track the host has seen is the host's problem now, not the outbox's.
|
||||
4. **Merge into player queue** (when auto-approve is on, and the suggestion isn't host-authored or already merged). Each merged track gets sprinkled at a random position in the upcoming range so host picks and guest suggestions interleave.
|
||||
5. **Maybe shuffle.** If auto-shuffle is on and the interval elapsed, Fisher–Yates-reorder the upcoming play queue + rewrite `state.lastShuffle`.
|
||||
6. **Snapshot playback.** Write `isPlaying`, `positionMs`, `positionAt` (wall-clock), `currentTrack`, and a 30-item slice of the upcoming play queue (`playQueue`) into the state blob.
|
||||
7. **Write.** Serialise and push to the session playlist's comment via `updatePlaylist.view`.
|
||||
|
||||
Host also writes a heartbeat to its own outbox every 10 s so the participants pipeline treats the host symmetrically.
|
||||
|
||||
### The guest tick
|
||||
|
||||
Fired from `useOrbitGuest` — fast polling (500 ms) until the first successful sync lands, then steady 2.5 s:
|
||||
|
||||
1. **Read the session playlist comment** via `getPlaylist.view`. Parse the OrbitState.
|
||||
2. **Check for session death:** comment empty → session-ended; `state.ended === true` → session-ended; `state.positionAt` older than 5 min → host-timeout.
|
||||
3. **Check kick / remove:** if the local username is in `state.kicked` or has a fresh entry in `state.removed`, transition to the appropriate exit modal.
|
||||
4. **Reconcile pending suggestions.** For every trackId the local client has submitted, check if it's appeared in `state.playQueue` or `state.currentTrack`. If so, drop it from the local "pending" list (the UI hides it automatically).
|
||||
5. **Auto-sync to host.** Three cases:
|
||||
- Different track at host → load it locally (`playTrack`), seek to `estimateLivePosition(state, now)`, mirror `isPlaying`. Never touches the local player if the guest has locally diverged (paused on their own).
|
||||
- Same track, play/pause flipped at host → mirror only if the guest hasn't locally diverged since the last tick.
|
||||
- First tick after join → mirror unconditionally (initial sync).
|
||||
6. **Heartbeat tick** (independent, every 10 s): write `{ ts: Date.now() }` into the guest outbox comment.
|
||||
|
||||
### Data flow
|
||||
|
||||
```
|
||||
Host (per tick) Navidrome Guest (per tick)
|
||||
──────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
player.currentTrack
|
||||
+ position
|
||||
│
|
||||
▼
|
||||
snapshotPlayerPatch ──► writeOrbitState ─┐
|
||||
│
|
||||
┌─session playlist─┐
|
||||
│ comment = JSON │ ◄─readOrbitState
|
||||
└──────────────────┘
|
||||
│
|
||||
▼
|
||||
parse OrbitState
|
||||
│
|
||||
▼
|
||||
syncToHost:
|
||||
• getSong
|
||||
• playTrack
|
||||
• seek
|
||||
• resume/pause
|
||||
|
||||
|
||||
Guest suggests track Y
|
||||
┌────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
▼ │
|
||||
suggestOrbitTrack
|
||||
┌──guest outbox──┐
|
||||
│ track list = Y │ ◄─updatePlaylist
|
||||
└────────────────┘
|
||||
│
|
||||
│
|
||||
Host: sweepGuestOutboxes ◄──────────┘
|
||||
│
|
||||
▼
|
||||
applyOutboxSnapshotsToState
|
||||
(queue += Y, participants refreshed)
|
||||
│
|
||||
▼ (if auto-approve)
|
||||
mergeNewSuggestionsIntoQueue
|
||||
│
|
||||
▼
|
||||
player.enqueueAt ──► playQueue snapshot ──► session playlist ──► Guest reconciles
|
||||
pending list
|
||||
```
|
||||
|
||||
### State shape
|
||||
|
||||
All relevant types in `src/api/orbit.ts`:
|
||||
|
||||
```ts
|
||||
interface OrbitState {
|
||||
v: 3;
|
||||
sid: string; // 8 hex chars
|
||||
host: string; // navidrome username
|
||||
name: string; // human-readable session name
|
||||
started: number; // ms since epoch
|
||||
maxUsers: number;
|
||||
currentTrack: OrbitQueueItem | null;
|
||||
isPlaying: boolean;
|
||||
positionMs: number;
|
||||
positionAt: number; // wall-clock ms of the last snapshot
|
||||
queue: OrbitQueueItem[]; // suggestion history
|
||||
playQueue: OrbitQueueItem[]; // 30-item slice of host's upcoming
|
||||
playQueueTotal: number;
|
||||
participants: OrbitParticipant[];
|
||||
kicked: string[];
|
||||
removed: { user: string; at: number }[];
|
||||
lastShuffle: number;
|
||||
settings: {
|
||||
autoApprove: boolean;
|
||||
autoShuffle: boolean;
|
||||
shuffleIntervalMin: 1 | 5 | 10 | 15 | 30;
|
||||
};
|
||||
ended: boolean;
|
||||
}
|
||||
|
||||
interface OrbitQueueItem {
|
||||
trackId: string;
|
||||
addedBy: string; // navidrome username
|
||||
addedAt: number;
|
||||
}
|
||||
```
|
||||
|
||||
The state blob is size-bounded to 4 KB (serialised JSON). `serialiseOrbitState` throws `OrbitStateTooLarge` above the budget so callers can trim optional fields and retry.
|
||||
|
||||
### Cleanup
|
||||
|
||||
Two layers of defense against orphaned playlists:
|
||||
|
||||
1. **Explicit exit.** `endOrbitSession` (host) or `leaveOrbitSession` (guest) deletes the participant's own playlists synchronously. The happy path.
|
||||
2. **App-start orphan sweep.** Every app launch runs `cleanupOrphanedOrbitPlaylists`: lists every `__psyorbit_*` playlist the current user owns, parses the heartbeat from the comment, deletes anything with a heartbeat older than 5 minutes (or `ended: true`, or an unparseable comment). The current local session is always protected.
|
||||
|
||||
The 5-minute TTL is a conservative compromise: long enough to survive a brief app restart (and a session running on another device of yours), short enough that a dead session doesn't clutter the server indefinitely.
|
||||
|
||||
### Security & privacy
|
||||
|
||||
- **Authentication.** Uses Navidrome's own user system. Participants are identified by their username; no additional auth layer.
|
||||
- **Public playlist visibility.** Session and outbox playlists must be `public: true` so guests can read them. Side effect: they're visible to *any* user on the same Navidrome instance while the session is active. Psysonic's own UI filters them; the Navidrome web client does not.
|
||||
- **No external servers.** Orbit is strictly peer-to-peer via the Navidrome instance you already trust. No data leaves your server.
|
||||
- **No message signing.** Since everything is owned by authenticated Navidrome users, we rely on the server's own ACLs. A guest can't modify the host's session playlist (different owner), only their own outbox.
|
||||
- **Track IDs only.** The state blob references tracks by their Navidrome ID. No filenames, no paths, no stream URLs.
|
||||
|
||||
---
|
||||
|
||||
## Edge cases handled
|
||||
|
||||
- **Host offline < 15 s.** Silent. Guests extrapolate via `estimateLivePosition` (positionMs + elapsed wall-clock).
|
||||
- **Host offline 15 s – 5 min.** Guest UI shows a yellow "Host offline" badge next to the session name. Playback continues locally.
|
||||
- **Host offline > 5 min.** Guest auto-leaves with a "Host went silent" modal. Cleanup of guest outbox runs on dismissal.
|
||||
- **Guest pauses locally.** The guest's local pause survives host track changes — the next-track event won't silently un-pause them. "Catch up" brings them back in sync.
|
||||
- **Guest resume in orbit.** Pressing play (player bar, media keys, MPRIS) in an active session is interpreted as "catch up" — loads the host's current track and seeks to the live position, not "resume the locally frozen track".
|
||||
- **Bulk "Play All" in-session.** Dialog: "Add 14 tracks to the Orbit queue?" On confirm, appended. On cancel, no-op.
|
||||
- **Single-click on song row in-session.** Swallowed; shows "Double-click to add" toast.
|
||||
- **Multiple accounts on target server.** Paste flow opens an account picker modal. Keyboard-navigable.
|
||||
- **Server switch while in session.** The session remains bound to its original server; changing the visible active server cannot redirect playlist reads, writes, suggestions or cleanup.
|
||||
- **Initial sync race.** The guest's first tick retries on 500 ms cadence until the player state actually matches the host's last-known track (up to 2 s per attempt, then falls through with a best-effort mirror).
|
||||
- **`positionAt` stale on join.** Seek fraction is clamped to [0, 0.99] — prevents `audio:ended` from firing at the very start of a join.
|
||||
- **Outbox deletion mid-session** (cleanup race): host sees the guest drop out on the next sweep; guest's next heartbeat recreates the outbox if they're still connected.
|
||||
- **Session playlist deleted** (cleanup race while the guest's local store says it's still active): guest treats as "ended", shows the exit modal.
|
||||
|
||||
---
|
||||
|
||||
## Code map
|
||||
|
||||
### State and types
|
||||
- `src/api/orbit.ts` — `OrbitState`, `OrbitQueueItem`, `OrbitSettings`, serialise/parse helpers, `estimateLivePosition`.
|
||||
- `src/store/orbitStore.ts` — local session state: role, phase, session/playlist ids, `pendingSuggestions`, `mergedSuggestionKeys`, `declinedSuggestionKeys`.
|
||||
|
||||
### Lifecycle
|
||||
- `src/utils/orbit.ts` — `startOrbitSession`, `joinOrbitSession`, `endOrbitSession`, `leaveOrbitSession`, `suggestOrbitTrack`, `approveOrbitSuggestion`, `declineOrbitSuggestion`, `hostEnqueueToOrbit`, `cleanupOrphanedOrbitPlaylists`, `effectiveShuffleIntervalMs`.
|
||||
- `src/utils/orbitBulkGuard.ts` — standalone confirm-dialog helper invoked from `playerStore` when `>1` tracks land in the queue while a session is active.
|
||||
- `src/utils/server/switchActiveServer.ts` — switches the active account without tearing down the source-bound Orbit session.
|
||||
|
||||
### Hooks
|
||||
- `src/hooks/useOrbitHost.ts` — host state tick + outbox sweep + merge pipeline + heartbeat.
|
||||
- `src/hooks/useOrbitGuest.ts` — guest state pull + auto-sync + heartbeat + host-timeout detection.
|
||||
- `src/hooks/useOrbitSongRowBehavior.ts` — shared double-click-to-add behaviour for song lists.
|
||||
|
||||
### UI — session bar and popovers
|
||||
- `src/components/OrbitSessionBar.tsx` — topbar strip with name, counts, shuffle timer, settings/share/help/catch-up/exit buttons.
|
||||
- `src/components/OrbitSettingsPopover.tsx` — host settings (auto-approve, auto-shuffle, interval, manual shuffle).
|
||||
- `src/components/OrbitSharePopover.tsx` — host-only invite-link popover with copy button.
|
||||
- `src/components/OrbitParticipantsPopover.tsx` — participant list with kick/ban (host-only actions).
|
||||
|
||||
### UI — modals
|
||||
- `src/components/OrbitStartModal.tsx` — session creation wizard.
|
||||
- `src/components/OrbitJoinModal.tsx` — manual invite-link paste + join.
|
||||
- `src/components/OrbitAccountPicker.tsx` — multi-account disambiguation when joining.
|
||||
- `src/components/OrbitExitModal.tsx` — session-ended / kicked / removed / host-timeout exit notice.
|
||||
- `src/components/OrbitHelpModal.tsx` — 9-section help walk-through (keyboard-navigable).
|
||||
- `src/components/OrbitStartTrigger.tsx` — "Psy Orbit" button in the header + launch popover (create / join / help).
|
||||
|
||||
### UI — queue views
|
||||
- `src/components/OrbitQueueHead.tsx` — shared header strip (session name, participants, host-presence badge).
|
||||
- `src/components/OrbitGuestQueue.tsx` — guest-side queue view (current track, pending suggestions, upcoming).
|
||||
- `src/components/HostApprovalQueue.tsx` — host-side approval strip with accept/decline.
|
||||
|
||||
### Supporting
|
||||
- `src/store/confirmModalStore.ts` + `src/components/GlobalConfirmModal.tsx` — promise-based confirm dialog used by the bulk-gate.
|
||||
- `src/store/helpModalStore.ts` + `src/components/OrbitHelpModal.tsx` — shared help-modal state.
|
||||
- `src/store/orbitAccountPickerStore.ts` + `src/components/OrbitAccountPicker.tsx` — account picker for multi-account server switch.
|
||||
@@ -1,56 +0,0 @@
|
||||
# Privacy Policy
|
||||
|
||||
Psysonic is a self-hosted music player. It does not collect telemetry, analytics, or any data on its own. All data stays on your device or travels exclusively between your device and services you explicitly configure.
|
||||
|
||||
## Data sent to third-party services
|
||||
|
||||
All third-party integrations listed below are **opt-in**. Nothing is sent until you enable the respective feature.
|
||||
|
||||
### Your Subsonic / Navidrome server
|
||||
Your server URL, username, and password are stored locally in the app's data directory. All playback and library requests go directly to your own server. Psysonic has no access to this data.
|
||||
|
||||
### Music Network (scrobble & enrichment services)
|
||||
Psysonic can connect to one or more scrobble services in Settings → Integrations. Each service you connect is opt-in and independent; nothing is sent to a service you have not connected. Supported service classes:
|
||||
|
||||
- **Audioscrobbler / GNU FM services** — Last.fm, Libre.fm, Rocksky (AT Protocol), and any self-hosted GNU FM-compatible instance
|
||||
- **ListenBrainz** — the public ListenBrainz.org service, or a self-hosted instance (e.g. Koito) via its ListenBrainz-compatible API
|
||||
- **Maloja** — your own self-hosted Maloja server (native API or its ListenBrainz-compatible API)
|
||||
|
||||
To each connected service, Psysonic may send:
|
||||
- **Scrobbles** — track title, artist, album, and timestamp when a song reaches 50% playback
|
||||
- **Now Playing** — the currently playing track (title, artist, album), where the service supports it
|
||||
- **Love / Unlove** — when you mark a track as loved, on services that support it
|
||||
|
||||
Additionally, the one service you choose as your **primary** is queried to enrich the UI (your loved tracks, similar artists, and listening stats). All requests go directly from your device to the service's own host — the public service's host (e.g. the [Last.fm API](https://www.last.fm/api), [ListenBrainz](https://listenbrainz.org)) or, for self-hosted services, the server URL you entered. Credentials (session keys / API tokens) are stored locally and never leave your device. You can disconnect any service at any time in Settings.
|
||||
|
||||
### LRCLIB (Lyrics)
|
||||
When lyrics are fetched from LRCLIB, Psysonic sends the track title, artist, album, and duration to [lrclib.net](https://lrclib.net) as a search query. No account is required. This feature can be disabled in Settings → Lyrics.
|
||||
|
||||
### YouLyPlus (Lyrics)
|
||||
If YouLyPlus mode is selected in Settings → Lyrics, Psysonic sends the track title, artist, album, duration, and ISRC (when available) to a community-operated [lyricsplus](https://github.com/ibratabian17/lyricsplus) backend to fetch word-synced karaoke lyrics. No account is required. Requests are routed through a list of public mirrors; the data they receive is limited to the search query above. This feature is disabled by default.
|
||||
|
||||
### NetEase Cloud Music (Lyrics)
|
||||
If NetEase is enabled as a lyrics source in Settings → Lyrics, Psysonic sends the track artist and title to the NetEase Cloud Music API (via a Rust-side proxy request) to search for synced lyrics. No account is required. This feature is disabled by default.
|
||||
|
||||
### Apple Music / iTunes Search API
|
||||
If "Use Apple Music covers for Discord" is enabled in Settings, Psysonic queries the [iTunes Search API](https://developer.apple.com/library/archive/documentation/AudioVideo/Conceptual/iTuneSearchAPI/) with the current track's artist and album name to find cover art. No Apple account is required. Apple's own privacy policy applies to these requests.
|
||||
|
||||
### Discord Rich Presence
|
||||
If Discord is running and Rich Presence is not disabled, Psysonic connects to the local Discord client via its IPC socket to display the currently playing track. This data is sent to Discord and subject to [Discord's privacy policy](https://discord.com/privacy). No data is sent if Discord is not installed or not running.
|
||||
|
||||
## Data stored locally
|
||||
|
||||
The following data is stored exclusively on your device in the app's local storage directory and is never transmitted:
|
||||
|
||||
- Server profiles (URL, username, password)
|
||||
- Scrobble service credentials (session keys / API tokens)
|
||||
- Playback preferences, themes, keybindings, and all other settings
|
||||
- Synced device manifests
|
||||
|
||||
## No telemetry
|
||||
|
||||
Psysonic contains no crash reporting, analytics, usage tracking, or any form of telemetry.
|
||||
|
||||
## Open source
|
||||
|
||||
Psysonic is fully open source under the [GNU General Public License v3.0](LICENSE). You can verify exactly what data is sent by reading the source code.
|
||||
@@ -1,292 +1,89 @@
|
||||
<div align="center">
|
||||
|
||||
<img src="public/psysonic-inapp-logo.svg" alt="Psysonic Logo" width="320"/>
|
||||
|
||||
## A modern desktop client for self-hosted music libraries
|
||||
|
||||
**Fast. Native. Beautiful. Built for people who actually care about their music collection.**
|
||||
|
||||
Psysonic is built primarily for **Navidrome** and also works with **Gonic**, **Airsonic**, **LMS** and other Subsonic-compatible servers, depending on the features supported by your server.
|
||||
|
||||
<br>
|
||||
|
||||
<a href="https://github.com/Psychotoxical/psysonic/releases/latest"><img src="https://img.shields.io/github/v/release/Psychotoxical/psysonic?style=for-the-badge&label=Latest%20Release&color=8b5cf6" alt="Latest Release"></a> <a href="https://github.com/Psychotoxical/psysonic/stargazers"><img src="https://img.shields.io/github/stars/Psychotoxical/psysonic?style=for-the-badge&color=f59e0b" alt="GitHub Stars"></a> <a href="https://github.com/Psychotoxical/psysonic/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-GPLv3-22c55e?style=for-the-badge" alt="License GPLv3"></a> <a href="https://tauri.app/"><img src="https://img.shields.io/badge/Tauri-v2-0f172a?style=for-the-badge&logo=tauri" alt="Tauri v2"></a>
|
||||
|
||||
<a href="https://discord.gg/AMnDRErm4u"><img src="https://img.shields.io/badge/Discord-Community-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Discord Community"></a> <a href="https://t.me/+GLBx1_xeH28xYTJi"><img src="https://img.shields.io/badge/Telegram-Community-26A5E4?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Community"></a> <a href="https://ko-fi.com/psychotoxic"><img src="https://img.shields.io/badge/Ko--fi-Support%20Psysonic-FF5E5B?style=for-the-badge&logo=ko-fi&logoColor=white" alt="Support Psysonic on Ko-fi"></a>
|
||||
|
||||
<a href="https://aur.archlinux.org/packages/psysonic"><img src="https://img.shields.io/badge/AUR-psysonic-1793d1?style=for-the-badge&logo=arch-linux&logoColor=white" alt="AUR psysonic"></a> <a href="https://aur.archlinux.org/packages/psysonic-bin"><img src="https://img.shields.io/badge/AUR-psysonic--bin-1793d1?style=for-the-badge&logo=arch-linux&logoColor=white" alt="AUR psysonic-bin"></a> <a href="https://psysonic.cachix.org"><img src="https://img.shields.io/badge/Cachix-psysonic.cachix.org-5277C3?style=for-the-badge&logo=nixos&logoColor=white" alt="Cachix"></a> <a href="https://github.com/microsoft/winget-pkgs/tree/master/manifests/p/Psychotoxical/Psysonic"><img src="https://img.shields.io/badge/WinGet-psysonic-blue?style=for-the-badge&logo=windows" alt="WinGet psysonic"></a>
|
||||
|
||||
<br><br>
|
||||
|
||||
**Available languages:** English, German, Spanish, French, Norwegian Bokmål, Dutch, Romanian, Russian, Chinese, Japanese, Hungarian and Polish.
|
||||
|
||||
More translations are added over time.
|
||||
|
||||
**No telemetry • Native performance • Navidrome-first • Community driven**
|
||||
|
||||
<img src="public/logo.png" alt="Psysonic Logo" width="200"/>
|
||||
<h1>Psysonic</h1>
|
||||
<p><strong>A modern, gorgeous, and blazing fast desktop client for Subsonic API compatible music servers (Navidrome, Gonic, etc.).</strong></p>
|
||||
|
||||
<p>
|
||||
<a href="https://github.com/Psychotoxical/psysonic/releases/latest"><img alt="Latest Release" src="https://img.shields.io/github/v/release/Psychotoxical/psysonic?style=flat-square&color=8839ef"></a>
|
||||
<a href="https://github.com/Psychotoxical/psysonic/blob/main/LICENSE"><img alt="License" src="https://img.shields.io/github/license/Psychotoxical/psysonic?style=flat-square&color=cba6f7"></a>
|
||||
<a href="https://tauri.app/"><img alt="Built with Tauri" src="https://img.shields.io/badge/Built%20with-Tauri-242938?style=flat-square&logo=tauri"></a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||

|
||||
> [!WARNING]
|
||||
> **Beta Release (v0.1.0):** This is the very first public release. While fully usable, you might encounter bugs. Additionally, the English translation is currently incomplete in some areas.
|
||||
|
||||
---
|
||||
Psysonic is a beautiful desktop music player built completely from the ground up for the modern era. Utilizing **Tauri v2** and **React**, it offers a native-feeling, lightweight, and incredibly fast experience with a stunning UI inspired by the [Catppuccin](https://github.com/catppuccin/catppuccin) aesthetic.
|
||||
|
||||
## What is Psysonic?
|
||||
Designed specifically for users hosting their own music via Navidrome or other Subsonic API servers, Psysonic aims to be the best way to interact with your personal library.
|
||||
|
||||
Psysonic is a desktop music client for self-hosted music libraries. It is designed for people who want the freedom of their own server without giving up the comfort, polish and speed of a modern music app.
|
||||

|
||||
|
||||
It is built with **Rust**, **Tauri v2** and **React**, with a strong focus on responsiveness, customization, practical music-library workflows and a user interface that does not require a manual before you can press play.
|
||||
## ✨ Features
|
||||
|
||||
Psysonic is **optimized first and foremost for Navidrome**, and it leans into that on purpose: instead of being one more generic Subsonic client, it is **the Navidrome-first desktop client that does things no other client does.** Other Subsonic-compatible servers can work well too, but advanced features may depend on server-side support.
|
||||
- 🎨 **Gorgeous UI**: Deeply integrated Catppuccin themes (Mocha & Latte) with smooth glassmorphism effects and micro-animations.
|
||||
- ⚡ **Blazing Fast**: Built with Rust & Tauri, resulting in minimal RAM usage compared to typical Electron apps.
|
||||
- 🌍 **Internationalization (i18n)**: Fully translated into English and German, with the architecture built to easily support more languages.
|
||||
- 📻 **Live "Now Playing"**: See what other users on your server are currently listening to in real-time.
|
||||
- 🎵 **Last.fm Scrobbling**: Full integration for scrobbling your tracks via the Navidrome server.
|
||||
- 💾 **Local Caching**: Fast loading times with customizable image caching thresholds.
|
||||
- 💿 **Album & Artist Views**: Beautiful grid displays and detailed artist pages with related albums.
|
||||
- 🎛️ **Queue Management**: Drag & drop support, playlist saving, and loading directly built into the queue.
|
||||
- 🖥️ **Cross-Platform**: Available natively for Windows, macOS, and Linux.
|
||||
|
||||
---
|
||||
## 📥 Installation
|
||||
|
||||
# ⭐ Key Features
|
||||
Navigate to the [Releases](https://github.com/Psychotoxical/psysonic/releases) page and download the installer for your operating system.
|
||||
|
||||
These are the things that set Psysonic apart. To our knowledge, no comparable self-hosted desktop client ships them.
|
||||
- **Windows**: `.exe` or `.msi`
|
||||
- **macOS**: `.dmg`
|
||||
- **Linux**: `.AppImage` or `.deb`
|
||||
|
||||
## 🪐 Orbit — Shared Listening
|
||||
## 🚀 Getting Started
|
||||
|
||||
**Listen together, in sync, over your own server.**
|
||||
1. Download and install Psysonic.
|
||||
2. Open the app and enter your Subsonic/Navidrome server details (URL, Username, Password).
|
||||
3. If applicable, you can provide both an external URL and a local LAN IP. Psysonic allows you to quickly toggle between them in the Settings.
|
||||
4. Enjoy your music!
|
||||
|
||||
Orbit brings real-time synchronized group listening into Psysonic. Start a session, invite people with a link, and everyone hears the same thing at the same time — with host-controlled playback, a shared queue and guest song suggestions.
|
||||
## 🛠️ Development
|
||||
|
||||
The clever part: Orbit rides entirely on **your own Navidrome**. There is no external relay, no third-party service and no extra accounts. The session lives on your server, where it belongs. It is built for real-world music sharing without turning your self-hosted setup into a social-media circus.
|
||||
If you want to build Psysonic from source or contribute to the project:
|
||||
|
||||
<div align="left">
|
||||
<img src="public/orbit.png" alt="Orbit shared listening" width="520"/>
|
||||
</div>
|
||||
### Prerequisites
|
||||
- [Node.js](https://nodejs.org/) (v18+)
|
||||
- [Rust](https://www.rust-lang.org/)
|
||||
- OS-specific build dependencies for Tauri (see the [Tauri prerequisites guide](https://tauri.app/v1/guides/getting-started/prerequisites)).
|
||||
|
||||
## ⚡ Local Library — Instant, and Almost Offline
|
||||
|
||||
**A local index of your whole collection, so the app stays fast no matter what your connection does.**
|
||||
|
||||
Psysonic keeps a local library of your collection's metadata right on your machine. Because the app already knows your tracks inside out, browsing, searching and starting playback are instant — even a 500 MB FLAC starts the moment you hit play, because nothing has to be fetched or parsed first.
|
||||
|
||||
It also means the connection to your server stops being a bottleneck. Even on a slow, flaky or distant link, Psysonic stays responsive and **behaves almost like an offline player**, whatever the network is doing.
|
||||
|
||||
And it's the foundation everything else is built on: the local library is what makes on-device analysis, smart audio and snappy navigation possible in the first place.
|
||||
|
||||
## 🧠 On-Device Audio Analysis
|
||||
|
||||
**One of the most powerful things Psysonic does — entirely on your own machine.**
|
||||
|
||||
Built on top of the local library, Psysonic analyzes your tracks locally — **loudness, waveform and tempo** — with no cloud service and no required server-side plugin. That analysis is what powers content-aware AutoDJ transitions, LUFS-based loudness normalization and playback-speed control.
|
||||
|
||||
This is a deep, genuinely useful layer that most clients simply don't have, and because it runs locally, it works exactly the same whether you're fully online or barely connected.
|
||||
|
||||
## 🎧 AutoDJ — Content-Aware Crossfade
|
||||
|
||||
**A DJ that listens to the music, not a stopwatch.**
|
||||
|
||||
Most players do fixed-time crossfade: blend the last N seconds into the next N seconds, dead air and all. AutoDJ uses Psysonic's own audio analysis to **trim the silence at the edges of a track and blend out of the actual music** — for transitions that sound deliberate instead of mechanical. It is a standalone playback mode with smooth skip/interrupt handling and a configurable overlap.
|
||||
|
||||
## 🔗 Navidrome-Native, Deeply
|
||||
|
||||
**Not a generic Subsonic client wearing a Navidrome hat.**
|
||||
|
||||
Psysonic binds Navidrome's native capabilities directly: server-side smart-playlist create/edit, playback reporting and OpenSubsonic capability probing. Most clients in this space stay Subsonic-generic. Psysonic goes deeper, so Navidrome users get the features their server can actually deliver.
|
||||
|
||||
## 🎨 Community Theme Store
|
||||
|
||||
**A real marketplace for themes — installable and schedulable.**
|
||||
|
||||
Beyond a big set of built-in themes, Psysonic has a first-party theme registry: browse community themes, install them in-app, and let the **Theme Scheduler** switch looks automatically between day and night.
|
||||
|
||||
---
|
||||
|
||||
> ### Built to be trusted
|
||||
>
|
||||
> We take an enterprise-grade approach to development — continuously improving our automated testing and maintaining strict contracts between the backend and the frontend. Releases are cut from green CI, not vibes.
|
||||
|
||||
---
|
||||
|
||||
# ✨ More Highlights
|
||||
|
||||
Features that go well beyond the basics. Not all of these are unique to Psysonic, but few clients bring this many together.
|
||||
|
||||
## Audio & Loudness
|
||||
|
||||
* LUFS-based Smart Loudness Normalization
|
||||
* ReplayGain support and loudness-aware playback
|
||||
* 10-band Equalizer with presets
|
||||
* AutoEQ headphone correction
|
||||
* Per-device EQ and output optimization
|
||||
* Adjustable playback speed
|
||||
|
||||
## Lyrics & Listening
|
||||
|
||||
* Synced lyrics with seek support, from multiple providers ([YouLy+](https://github.com/ibratabian17/YouLyPlus), LRCLIB, NetEase)
|
||||
* Auto-scrolling sidebar lyrics and a fullscreen lyric mode
|
||||
* Last.fm scrobbling, similar artists, loved tracks and listening stats
|
||||
* Smart Radio sessions and an Infinite Queue
|
||||
* [AudioMuse-AI](https://github.com/NeptuneHub/AudioMuse-AI) support for sonic-similarity discovery (requires an AudioMuse-AI server)
|
||||
|
||||
## Artwork & Visuals
|
||||
|
||||
* Optional external artist imagery via **fanart.tv** — opt-in, shown on the artist page, fullscreen player and home hero (Navidrome stays the canonical cover-art source)
|
||||
* Cover art surfaced across the app, OS media controls and Discord Rich Presence
|
||||
|
||||
## Library & Playlists
|
||||
|
||||
* Smart Playlists
|
||||
* Drag & drop playlist management
|
||||
* Multi-select bulk actions
|
||||
|
||||
## Sharing
|
||||
|
||||
* Magic Strings sharing for albums, artists and queues
|
||||
* Navidrome user-management helpers for fast account sharing
|
||||
|
||||
## Offline, Sync & Deployment
|
||||
|
||||
* Offline playback and downloads
|
||||
* USB / portable sync
|
||||
* LAN / remote auto-switching
|
||||
* Custom HTTP headers for reverse-proxy-gated servers (e.g. Cloudflare Access, Pangolin)
|
||||
* Backup and restore settings
|
||||
* In-app auto updater
|
||||
|
||||
## Personalization & Accessibility
|
||||
|
||||
* Font customization and zoom controls
|
||||
* Keybind remapping
|
||||
* Colorblind-friendly theme options
|
||||
* Keyboard-friendly navigation
|
||||
|
||||
## Power-User Extras
|
||||
|
||||
* CLI controls
|
||||
|
||||
---
|
||||
|
||||
# ✅ The Basics, Done Right
|
||||
|
||||
The things you simply expect from a serious music player — and Psysonic does them well.
|
||||
|
||||
* Gapless playback and crossfade
|
||||
* Fast search across large libraries
|
||||
* Browse albums, artists, tracks and genres
|
||||
* Ratings
|
||||
* Queue management
|
||||
* Keyboard navigation
|
||||
* Media key support
|
||||
* Low memory usage and native performance compared to heavy web-first clients
|
||||
* Built for large self-hosted collections
|
||||
|
||||
---
|
||||
|
||||
# Platforms
|
||||
|
||||
| OS | Support |
|
||||
| ------- | --------------------------------------------------------------- |
|
||||
| Windows | Native installer / WinGet |
|
||||
| macOS | Signed DMG |
|
||||
| Linux | AppImage / DEB / RPM / AUR (`psysonic`, `psysonic-bin`) / NixOS |
|
||||
|
||||
---
|
||||
|
||||
# Install
|
||||
|
||||
## Linux
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Psychotoxical/psysonic/main/scripts/install.sh | sudo bash
|
||||
```
|
||||
|
||||
Linux builds are also available through GitHub Releases, AUR and Cachix/Nix.
|
||||
|
||||
> **AppImage runs under X11/XWayland** — it pins `GDK_BACKEND=x11` for a stable WebKitGTK stack. For a native-Wayland launch, use the `.deb`, `.rpm`, AUR, or Nix packages, which follow your session's display server.
|
||||
|
||||
## Windows
|
||||
|
||||
Download the latest installer from the [GitHub Releases](https://github.com/Psychotoxical/psysonic/releases/latest).
|
||||
or,
|
||||
install via Windows Package Manager (WinGet):
|
||||
```powershell
|
||||
winget install Psysonic
|
||||
```
|
||||
|
||||
You can also browse and install it on [winstall.app](https://winstall.app/apps/Psychotoxical.Psysonic).
|
||||
|
||||
## macOS
|
||||
|
||||
Download the signed DMG from the [GitHub Releases](https://github.com/Psychotoxical/psysonic/releases/latest).
|
||||
|
||||
---
|
||||
|
||||
# Development
|
||||
|
||||
Contributor expectations (PRs, CI, Tauri boundary, UI): [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/Psychotoxical/psysonic.git
|
||||
cd psysonic
|
||||
|
||||
# Install node dependencies
|
||||
npm install
|
||||
|
||||
# Run in development mode
|
||||
npm run tauri:dev
|
||||
```
|
||||
|
||||
Build release:
|
||||
|
||||
```bash
|
||||
# Build for production
|
||||
npm run tauri:build
|
||||
```
|
||||
|
||||
---
|
||||
## 🤝 Contributing
|
||||
|
||||
# Privacy
|
||||
Contributions are completely welcome! Whether it is translating the app into a new language, fixing a bug, or proposing a new feature.
|
||||
|
||||
Psysonic is built for self-hosted music collections. Your library is yours.
|
||||
1. Fork the Project
|
||||
2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`)
|
||||
3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`)
|
||||
4. Push to the Branch (`git push origin feature/AmazingFeature`)
|
||||
5. Open a Pull Request
|
||||
|
||||
* No telemetry
|
||||
* No spyware nonsense
|
||||
* No analytics harvesting
|
||||
* No hidden tracking
|
||||
## 📄 License
|
||||
|
||||
See [TELEMETRY.md](TELEMETRY.md) for the telemetry stance and [PRIVACY.md](PRIVACY.md) for how each opt-in integration handles data.
|
||||
|
||||
---
|
||||
|
||||
# Reviews
|
||||
|
||||
* [An independent review at falu.github.io](https://falu.github.io/2026/06/19/psysonic.html)
|
||||
|
||||
---
|
||||
|
||||
# Community & Support
|
||||
|
||||
Join the community, report bugs, suggest features, share themes and help shape the future of Psysonic.
|
||||
|
||||
* [Discord](https://discord.gg/AMnDRErm4u)
|
||||
* [Telegram](https://t.me/+GLBx1_xeH28xYTJi)
|
||||
* [GitHub Issues](https://github.com/Psychotoxical/psysonic/issues)
|
||||
* [Support Psysonic on Ko-fi](https://ko-fi.com/psychotoxic)
|
||||
|
||||
---
|
||||
|
||||
# License
|
||||
|
||||
Psysonic is licensed under the **GNU GPL v3.0**.
|
||||
|
||||
---
|
||||
|
||||
## Forks and Attribution
|
||||
|
||||
Psysonic is free and open-source software under the GPLv3. You are welcome to fork it, modify it and build upon it under the terms of the license.
|
||||
|
||||
If you publish a modified or rebranded version, please make it clear that your project is based on Psysonic and preserve proper attribution to the original project.
|
||||
|
||||
That is not about preventing forks. Forks are part of open source. It is about being honest with users and contributors about where the work comes from.
|
||||
|
||||
Features, design work and implementation ideas developed in Psysonic should not be presented as unrelated original work in downstream projects.
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
## Own your music. Enjoy the client too.
|
||||
|
||||
**Psysonic brings a modern desktop experience to self-hosted music libraries.**
|
||||
|
||||
</div>
|
||||
Distributed under the MIT License. See `LICENSE` for more information.
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
# Release Process (Strict SOP)
|
||||
|
||||
This document defines the **only allowed** release workflow for this repository.
|
||||
All maintainers should follow it exactly.
|
||||
|
||||
## 1) Branch roles
|
||||
|
||||
- `main`:
|
||||
- primary development branch
|
||||
- all regular feature/fix work lands here via PR
|
||||
- should usually carry a development version (for example `X.Y.Z-dev`)
|
||||
- `next`:
|
||||
- release-candidate (RC) stabilization branch
|
||||
- receives promoted changes from `main`
|
||||
- receives RC-only fixes during freeze
|
||||
- `release`:
|
||||
- stable release branch
|
||||
- only receives promoted commits from `next`
|
||||
|
||||
Direct push to these branches is not part of normal human workflow. Use PRs and promotion workflows.
|
||||
|
||||
## 2) Versioning rules (mandatory)
|
||||
|
||||
Version is authoritative in `package.json` and `package-lock.json`. Promotion workflows run `scripts/sync-tauri-version-from-package.js`, which also aligns `[workspace.package]` in `src-tauri/Cargo.toml`, `tauri.conf.json`, and the `psysonic*` workspace crate version fields in `src-tauri/Cargo.lock` (local `cargo build` alone does not commit that lock metadata).
|
||||
|
||||
- `main` version format: `X.Y.Z-dev`
|
||||
- `next` version format: `X.Y.Z-rc.N`
|
||||
- `release` version format: `X.Y.Z`
|
||||
|
||||
WiX/MSI bundle version: alphabetic pre-releases (`-dev`, `-rc.N`) map to monotonic `major.minor.patch.build` in `bundle.windows.wix.version` via `scripts/sync-wix-bundle-version.mjs` — dev `.1`, rc `.10000+N`, stable `.65534` (in-place MSI upgrade across promotion). About/updater still show the real package version. NSIS accepts full semver without this mapping.
|
||||
|
||||
Rules:
|
||||
|
||||
1. Never edit versions manually in random commits.
|
||||
2. Version transitions must happen through the defined promotion workflows.
|
||||
3. Tags must match package version:
|
||||
- RC: `app-vX.Y.Z-rc.N`
|
||||
- Stable: `app-vX.Y.Z`
|
||||
|
||||
## 3) Standard release flow
|
||||
|
||||
### Step A: Prepare in `main`
|
||||
|
||||
1. Merge ready PRs into `main`.
|
||||
2. Confirm CI is green on `main`.
|
||||
3. Refresh the bundled Open-Source-Licenses data (maintainer-only, run locally):
|
||||
- Requires `cargo-about` (one-time install: `cargo install cargo-about --features cli`).
|
||||
- Run directly: `node scripts/generate-licenses.mjs` from the repo root.
|
||||
- Inspect the diff on `src/data/licenses.json` (size + entry count delta should be plausible).
|
||||
- Commit on `main` if the file changed. No npm script wrapper exists on purpose — adding one to `package.json` would trigger the `nix-npm-deps-hash-sync` workflow on every push. Contributors consume the committed JSON as-is.
|
||||
|
||||
### Step B: Promote to RC (`next`)
|
||||
|
||||
0. Confirm `WHATS_NEW.md` has a `## [X.Y.Z]` section for the release line about to ship (user-facing copy for the in-app What's New screen; CI uploads it as `whats-new.md` on the release tag).
|
||||
1. Run workflow: **Promote main to next**.
|
||||
2. Workflow behavior:
|
||||
- validates required `main` checks before promotion (default: `ci-ok`, or UI-style `ci-main / ci-ok`; either satisfies the gate)
|
||||
- resets `next` to `main` snapshot
|
||||
- auto-bump package version in `next` to next `-rc.N`
|
||||
- commit and push version bump
|
||||
3. Push on `next` triggers **Next Channel** workflow:
|
||||
- build/publish RC artifacts for all platforms
|
||||
- run Nix verification path
|
||||
|
||||
### Step C: Stabilize RC
|
||||
|
||||
1. Test RC artifacts.
|
||||
2. If fixes are needed, follow Section 5 (RC fix policy).
|
||||
3. Repeat Step B as needed until release candidate is accepted.
|
||||
|
||||
### Step D: Promote to stable (`release`)
|
||||
|
||||
1. Run workflow: **Promote next to release**.
|
||||
2. Workflow behavior:
|
||||
- resets `release` to `next` snapshot
|
||||
- finalize version from `-rc.N` to `X.Y.Z`
|
||||
- commit and push finalized version
|
||||
3. Push on `release` triggers **Release Channel** workflow:
|
||||
- stable artifact publish
|
||||
- Nix verification
|
||||
- opens PR to bump `main` to next minor `-dev`
|
||||
|
||||
### Step E: Move `main` forward
|
||||
|
||||
1. Merge the auto-generated PR that bumps `main` to next minor dev version.
|
||||
2. Confirm `main` now uses `X.(Y+1).0-dev`.
|
||||
3. Update AUR package metadata for the same stable version:
|
||||
- bump `pkgver` in `packages/aur/PKGBUILD`
|
||||
- regenerate `packages/aur/.SRCINFO`
|
||||
- publish/update in AUR remote
|
||||
|
||||
## 4) Freeze policy (RC stabilization window)
|
||||
|
||||
When RC freeze starts:
|
||||
|
||||
- Do **not** run `Promote main to next` automatically or casually.
|
||||
- Only approved release manager(s) may run promotion workflows.
|
||||
- `next` accepts only stabilization changes (fixes/docs/chore required for release quality).
|
||||
- New features remain in `main` and wait for next cycle.
|
||||
|
||||
Freeze ends after `next -> release` promotion is complete.
|
||||
|
||||
## 5) RC fix policy (strict backport/forward-port rules)
|
||||
|
||||
If a bug is discovered during RC stabilization:
|
||||
|
||||
1. Create dedicated fix branch from `next`:
|
||||
- example: `fix/rc-crash-login`
|
||||
2. Open PR: `fix/rc-crash-login -> next`
|
||||
3. After merge to `next`, create dedicated backport branch from `main`:
|
||||
- example: `fix/backport-rc-crash-login-main`
|
||||
4. Cherry-pick (or re-apply) same fix.
|
||||
5. Open PR: `fix/backport-rc-crash-login-main -> main`
|
||||
6. Merge this `main` backport PR before the next `Promote main to next` run.
|
||||
|
||||
This is mandatory. RC-only fixes may not stay only in `next`.
|
||||
|
||||
Alternative allowed order:
|
||||
|
||||
- implement first in `main`, then promote `main -> next`.
|
||||
|
||||
But if `main` is ahead with non-release features and promotion is frozen, use the `next-first + mandatory main backport` flow above.
|
||||
|
||||
## 6) Post-release critical hotfix policy (default path)
|
||||
|
||||
After a stable release `X.Y.Z`, critical fixes must be shipped as a patch release:
|
||||
|
||||
- next stable target is always `X.Y.(Z+1)`
|
||||
- RC tags for hotfix cycle: `app-vX.Y.(Z+1)-rc.N`
|
||||
- final stable tag: `app-vX.Y.(Z+1)`
|
||||
|
||||
Never re-use or overwrite `X.Y.Z` tags/releases.
|
||||
|
||||
### Case A: `next` is not yet used for the next minor
|
||||
|
||||
This case is uncommon in this repository but allowed.
|
||||
|
||||
1. Create hotfix branch from `release`.
|
||||
2. Implement fix and open PR to `release`.
|
||||
3. Move patch line through `next` RC flow (`X.Y.(Z+1)-rc.N`).
|
||||
4. Promote `next -> release` for final `X.Y.(Z+1)`.
|
||||
5. Backport fix to `main` via dedicated PR (mandatory).
|
||||
|
||||
### Case B (default): `next` already tracks next minor
|
||||
|
||||
This is the expected real-world case.
|
||||
|
||||
Assume:
|
||||
|
||||
- `release` is `1.9.0`
|
||||
- `main`/`next` already moved to `1.10.0-*`
|
||||
- critical bug requires `1.9.1`
|
||||
|
||||
Required steps:
|
||||
|
||||
1. Announce **hotfix override window** and freeze normal next-minor RC flow.
|
||||
2. Create hotfix branch from `release` (`1.9.0` baseline).
|
||||
3. Implement fix and merge into `release` branch via PR.
|
||||
4. Temporarily align `next` to the hotfix patch line for RC publication.
|
||||
5. Publish hotfix RC(s): `1.9.1-rc.N`.
|
||||
6. Promote `next -> release` to finalize `1.9.1`.
|
||||
7. Backport/cherry-pick same fix into `main` via dedicated PR (mandatory).
|
||||
8. Restore `next` back to the normal next-minor line from `main`.
|
||||
9. Announce end of hotfix override and resume normal RC cycle.
|
||||
|
||||
Hard rule: no feature work may be merged into `next` during hotfix override.
|
||||
|
||||
## 7) Idempotency and rerun behavior
|
||||
|
||||
Manual workflow reruns should be safe:
|
||||
|
||||
- rerunning **Promote main to next**:
|
||||
- no change if no new commits
|
||||
- version bump occurs only when needed for next RC number
|
||||
- rerunning **Promote next to release**:
|
||||
- no change if release already matches next
|
||||
- no extra version increment beyond `X.Y.Z`
|
||||
- rerunning release publish:
|
||||
- main dev bump step should no-op when `main` already has target dev version
|
||||
|
||||
Rerun is allowed for recovery, but must be announced in release channel/chat.
|
||||
|
||||
## 8) Hard rules and prohibitions
|
||||
|
||||
Do:
|
||||
|
||||
- use PRs for all code changes
|
||||
- keep channel promotions deterministic and force-push only through approved promotion workflows
|
||||
- require green CI before promotions
|
||||
- document exceptions in PR description
|
||||
|
||||
Do not:
|
||||
|
||||
- manually retag or overwrite release tags
|
||||
- manually edit `package.json` version outside defined release flow
|
||||
- merge feature PRs into `next` during freeze
|
||||
- skip the `next/release -> main` backport for RC fixes or hotfixes
|
||||
- force-push `next` or `release` manually outside promotion workflows
|
||||
|
||||
## 9) Incident handling
|
||||
|
||||
If an incorrect promotion happened:
|
||||
|
||||
1. Stop further promotions immediately.
|
||||
2. Announce incident and current branch SHAs.
|
||||
3. Create corrective PRs (do not use destructive git history rewrites on protected branches).
|
||||
4. Re-run affected workflows only after corrective PRs are merged.
|
||||
|
||||
## 10) Operator checklist (quick)
|
||||
|
||||
Before `main -> next`:
|
||||
|
||||
- [ ] `main` CI green
|
||||
- [ ] freeze status known
|
||||
- [ ] release manager approval
|
||||
- [ ] branch rules allow workflow `--force-with-lease` on `next`
|
||||
|
||||
Before `next -> release`:
|
||||
|
||||
- [ ] RC validation complete
|
||||
- [ ] all RC fixes merged to `next`
|
||||
- [ ] corresponding backports to `main` completed or queued with owners
|
||||
- [ ] branch rules allow workflow `--force-with-lease` on `release`
|
||||
|
||||
After stable release:
|
||||
|
||||
- [ ] verify stable artifacts exist
|
||||
- [ ] merge auto PR for next `-dev` bump in `main`
|
||||
- [ ] publish AUR update (`PKGBUILD` + `.SRCINFO`)
|
||||
- [ ] announce cycle close
|
||||
|
||||
For post-release hotfix:
|
||||
|
||||
- [ ] patch target decided: `X.Y.(Z+1)`
|
||||
- [ ] hotfix override for `next` announced
|
||||
- [ ] fix merged to release patch line
|
||||
- [ ] hotfix backport PR to `main` merged
|
||||
- [ ] `next` restored to normal next-minor line
|
||||
|
||||
Nix note:
|
||||
|
||||
- `nix-npm-deps-hash-sync.yml` runs on pushes to `main`, `next`, and `release`.
|
||||
- `verify-nix` in channel publish still performs full lock/hash refresh verification for release artifacts.
|
||||
- Channel-local nix refresh PRs are advisory and can be overwritten by later reset-based promotions.
|
||||
- If a nix refresh must survive release cycles, ensure the same change is merged into `main`.
|
||||
@@ -1,28 +0,0 @@
|
||||
# Security policy
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
**Please do not open a public GitHub issue for security problems.**
|
||||
|
||||
Report them privately so we can investigate and coordinate a fix before details are public:
|
||||
|
||||
- [Discord](https://discord.gg/AMnDRErm4u) — reach a maintainer directly
|
||||
- [Telegram](https://t.me/+GLBx1_xeH28xYTJi) — same
|
||||
|
||||
Include what you can: affected version, platform (Windows / macOS / Linux), steps to reproduce, and impact if known.
|
||||
|
||||
## What to expect
|
||||
|
||||
- We will acknowledge your report as soon as we can.
|
||||
- We will work with you on verification and timing of any public disclosure.
|
||||
- We do not offer a paid bug-bounty program; credit in the changelog or release notes is given when reporters want it and when it fits the fix.
|
||||
|
||||
## Scope notes
|
||||
|
||||
- **This repository** — Psysonic desktop application source.
|
||||
- **AUR packages** ([`psysonic`](https://aur.archlinux.org/packages/psysonic), [`psysonic-bin`](https://aur.archlinux.org/packages/psysonic-bin)) are maintained separately; packaging issues there should go through the AUR unless they reflect a vulnerability in the upstream app itself.
|
||||
- **Your music server** (Navidrome, Gonic, etc.) is outside this project's scope; report server-side issues to those projects.
|
||||
|
||||
## Secure development
|
||||
|
||||
Pull requests are reviewed on `main`. **Dependency security updates** are tracked via Dependabot (PRs only for reported vulnerabilities and their fix paths—not routine version bumps). For general contribution expectations, see [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
@@ -1,66 +0,0 @@
|
||||
# Privacy & Telemetry
|
||||
|
||||
Privacy is not an afterthought in Psysonic. It has been part of the project’s foundation from the very beginning.
|
||||
|
||||
Psysonic was built with the clear intention of giving users control over their own music experience without silently observing what they do. We believe that people should be able to use software on their own systems without being monitored by the developers behind it.
|
||||
|
||||
## No Telemetry
|
||||
|
||||
Psysonic does **not** collect telemetry.
|
||||
|
||||
That means:
|
||||
|
||||
- no usage statistics
|
||||
- no background tracking
|
||||
- no hidden analytics
|
||||
- no hardware or system profiling
|
||||
- no automatic crash or behavior reporting
|
||||
- no data harvesting of any kind
|
||||
|
||||
Psysonic itself does not collect, store, sell, analyze, or transmit personal usage data.
|
||||
|
||||
## A Conscious Decision
|
||||
|
||||
We are aware that telemetry can make software development easier.
|
||||
|
||||
Anonymous usage statistics, crash reports, platform information, and diagnostic data can help developers find bugs faster, understand which systems are used most often, and prioritize fixes more efficiently.
|
||||
|
||||
However, we made a conscious decision not to build Psysonic around that model.
|
||||
|
||||
For us, respecting user privacy is more important than collecting additional data for convenience. Modern software already tracks more than enough, often by default and often without users fully understanding what is being collected. Psysonic intentionally takes a different approach.
|
||||
|
||||
## External Services Are Opt-In
|
||||
|
||||
Some Psysonic features can communicate with external services, such as:
|
||||
|
||||
- Last.fm
|
||||
- Bandsintown
|
||||
- Discord Rich Presence
|
||||
- Fanart.tv
|
||||
|
||||
These integrations are optional and clearly presented as opt-in features. They are never required for using Psysonic.
|
||||
|
||||
If you enable one of these integrations, data may be transmitted to the respective external service provider as part of how that service works. Psysonic itself does not collect or process that data for its own analytics.
|
||||
|
||||
You decide which integrations you want to use.
|
||||
|
||||
## Community Feedback Instead of Silent Tracking
|
||||
|
||||
Instead of telemetry, Psysonic relies on direct community feedback.
|
||||
|
||||
Bug reports, feature requests, platform issues, and general discussions happen openly through community channels such as Discord and Telegram. Additional contact options, including WhatsApp and Facebook groups, are planned for the future.
|
||||
|
||||
We prefer talking to users directly over silently collecting information in the background.
|
||||
|
||||
This approach may sometimes require more communication, but it also keeps the relationship between the project and its users transparent and respectful.
|
||||
|
||||
## Our Position
|
||||
|
||||
Psysonic is built for people who want to enjoy their own music collection on their own terms.
|
||||
|
||||
No hidden tracking.
|
||||
No telemetry by default.
|
||||
No analytics quietly running in the background.
|
||||
|
||||
This is intentional, and it is not planned to change.
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
# Trademark Notice
|
||||
|
||||
The Psysonic source code is licensed under the GNU GPLv3.
|
||||
The **Psysonic name, logo, wordmark, visual brand identity, and the
|
||||
names of original Psysonic features are not** — they are excluded
|
||||
from the GPLv3 grant and remain the property of the Psysonic project
|
||||
maintainers.
|
||||
|
||||
## Covered Marks
|
||||
|
||||
- The name **"Psysonic"** (in any capitalization or stylization)
|
||||
- The Psysonic logo and icon (`public/psysonic-inapp-logo.svg`,
|
||||
app icons, tray icons, installer artwork)
|
||||
- The Psysonic wordmark and any derivative branding
|
||||
- The names of original Psysonic features that function as feature
|
||||
identity, in particular **"Orbit"**
|
||||
|
||||
## What You May Do
|
||||
|
||||
- Use the Psysonic name to factually refer to the upstream project
|
||||
(e.g. *"based on Psysonic"*, *"a fork of Psysonic"*, *"compatible
|
||||
with Psysonic"*).
|
||||
- Redistribute unmodified Psysonic builds under the Psysonic name,
|
||||
in accordance with the GPLv3.
|
||||
- Link to the official Psysonic repository, website, and community
|
||||
channels.
|
||||
|
||||
## What You May Not Do
|
||||
|
||||
- Use the Psysonic name, logo, or branding for a **modified or
|
||||
rebranded fork**, distribution, or derivative product.
|
||||
- Name a fork in a way that suggests it *is* Psysonic, an official
|
||||
release of Psysonic, or endorsed by the Psysonic project (e.g.
|
||||
"Psysonic Pro", "Psysonic Plus", "Psysonic Next", "MyPsysonic").
|
||||
- Reuse the Psysonic logo, icon set, or installer artwork in a fork
|
||||
or derivative product, even with modifications.
|
||||
- Continue to use the feature name **"Orbit"** in a fork or
|
||||
derivative. If your fork ships the Orbit feature (or code derived
|
||||
from it), you must rename the user-facing feature.
|
||||
- Present original Psysonic features (such as Orbit) as independent
|
||||
creations of a fork. See `NOTICE.md` for the attribution
|
||||
requirements that apply to derivative works under GPLv3 §7(b).
|
||||
- Imply sponsorship, endorsement, or affiliation with the Psysonic
|
||||
project where none exists.
|
||||
|
||||
## Forking Done Right
|
||||
|
||||
You are warmly invited to fork the code under the GPLv3.
|
||||
If you publish a fork or derivative, please:
|
||||
|
||||
1. Choose your own distinct project name and branding.
|
||||
2. Keep the GPLv3 license, copyright notices, and the `NOTICE.md`
|
||||
file intact (this is required, not optional — see `NOTICE.md`).
|
||||
3. State clearly that your project is *based on Psysonic* and link
|
||||
back to the upstream repository.
|
||||
4. Replace Psysonic-branded assets (logos, icons, installer art,
|
||||
wordmarks) with your own.
|
||||
5. If you ship features that originated in Psysonic (such as Orbit),
|
||||
rename the user-facing feature and credit Psysonic as the
|
||||
original designer and implementer.
|
||||
|
||||
## Questions
|
||||
|
||||
If you are unsure whether a planned use of the Psysonic name or
|
||||
branding is acceptable, please open an issue or contact the
|
||||
maintainers before publishing.
|
||||
@@ -1,361 +0,0 @@
|
||||
# What's New
|
||||
|
||||
User-facing release highlights for the in-app **What's New** screen. Maintainers refresh the
|
||||
current line before promoting to `next` / `release`. Technical details and PR credits stay in
|
||||
`CHANGELOG.md`.
|
||||
|
||||
Within each section, order by **user impact** (most noticeable first) — not PR merge order.
|
||||
`CHANGELOG.md` keeps strict PR order inside Added / Changed / Fixed.
|
||||
|
||||
|
||||
## [1.50.0]
|
||||
|
||||
## Highlights
|
||||
|
||||
### Multi-library filter — browse across your libraries
|
||||
|
||||
- The sidebar library picker now supports **multi-select with priority ordering** — browse, search, genres, and album/artist detail views aggregate across the libraries you pick and de-duplicate shared items by priority.
|
||||
- Cross-library matching normalises names per locale (German ß, Norwegian æ, French œ, Romanian ș/ț, Cyrillic ё/й); CJK titles are matched as-is.
|
||||
- The Genres page and album browse genre filter list the full catalog on large libraries when **All libraries** is selected.
|
||||
|
||||
### Navidrome public share links — listen without logging in
|
||||
|
||||
- Paste or search a Navidrome **public share** URL (`/share/{id}`) to preview the shared track list, then play the full queue with no server account.
|
||||
- Share playback stays isolated from your logged-in Navidrome queue — idle server play-queue pull cannot replace a share session while you are connected elsewhere.
|
||||
- While a share queue is active, **Save Playlist** is hidden in the queue toolbar; **Share** copies the original Navidrome `/share/{id}` page URL.
|
||||
|
||||
### Fullscreen player — Minimal, Immersive, and Prism
|
||||
|
||||
- **Settings → Appearance → Fullscreen player style** now offers three looks: **Minimal** (the current sharp view), **Immersive** (artist photo/backdrop with rail or Apple-style scrolling lyrics), and **Prism** (full-bleed artist backdrop, glass lyrics panel, and a single glass control bar).
|
||||
- In Immersive, **Show artist photo** and **Photo dimming** are configurable; Prism drives progress and the active lyric line from the cover-derived accent colour.
|
||||
|
||||
### Lyrics that follow the singer, word by word
|
||||
|
||||
- The **Server** lyrics source now highlights lyrics word by word as a track plays, so karaoke sync no longer needs the third-party YouLyPlus backend. It requires Navidrome 0.63 or newer and lyrics that carry word timing (TTML or Enhanced LRC files) — everything else keeps highlighting line by line. The requirements are spelled out under **Settings → Lyrics → Lyrics Sources**.
|
||||
- Embedded Enhanced LRC no longer prints raw word timing codes in the lyric text; FLAC, Ogg Vorbis, Opus, and Speex files with synced lyrics in the `SYNCEDLYRICS` tag show embedded lyrics again.
|
||||
|
||||
### Player bar — build your own, plus shuffle
|
||||
|
||||
- **Settings → Personalisation → Player bar** now also hides the **stop button** and shows the **album name** under the artist (off by default; clicking it opens the album). Star rating, favourite, love, playback speed, equalizer, and mini player can be **dragged into any order** you like — the section is no longer behind **Advanced**.
|
||||
- A **shuffle toggle** in the player bar shuffles the queue from the current track onwards while keeping the playing track in place; turning shuffle off restores the original order. It survives restarts and keeps Orbit guests in sync with the host. Hide the button from **Settings → Personalisation → Player bar** if you prefer.
|
||||
|
||||
### Track lists — optional album cover thumbnails
|
||||
|
||||
- Browse and queue track rows can show each track's **album** cover (per-disc art when the album has distinct disc covers).
|
||||
- **Settings → Appearance** adds separate toggles for queue vs browse tracklists; playlist, Favorites, and album-detail grids gain a flex-resize handle on the title column when covers are shown.
|
||||
- Album detail pages skip per-row cover thumbs when the album art is already shown above the list.
|
||||
|
||||
### Discord — Server cover art without exposing your login
|
||||
|
||||
- **Settings → Integrations → Discord → Cover art source** includes a **Server** option alongside **None** and **Apple Music**. It resolves artwork through the server's public album image link — never an authenticated URL that could expose your login credentials. Requires a publicly reachable server.
|
||||
|
||||
### Artists browse — album artists or track credits
|
||||
|
||||
- Toggle **Album artists** vs **Track artists** on the Artists page — album mode lists indexed album artists; track mode includes featured and guest performers from the local artist index. The choice persists across restarts like **Show artist images**.
|
||||
- Artist name search no longer depends on query letter case for Cyrillic and other non-ASCII names when the local library index is enabled.
|
||||
|
||||
### Theme Store — what's new on each theme
|
||||
|
||||
- Each theme card has an expandable **What's new** with per-version release notes from the author — including non-visual fixes.
|
||||
- Installed themes with an available update now appear at the top of the store list so you do not have to hunt for them.
|
||||
- **Settings → System → Contributors** lists community theme authors in a **Themes** section alongside app contributors; author names refresh quietly from the store in the background.
|
||||
|
||||
### Italian and Bulgarian — now in your language
|
||||
|
||||
- Psysonic is now available in **Italian (Italiano)** and **Bulgarian (Български)** — pick either from the language menu on the **Settings** and **Login** screens.
|
||||
|
||||
### Start minimized to tray
|
||||
|
||||
- New **Start Minimized to Tray** toggle under **Settings → System → Behavior** — the next cold start keeps the main window hidden and Psysonic runs from the system tray until you show it from the tray icon. Requires **Show Tray Icon**; applies on the next launch only.
|
||||
- Opening the main window from the tray after a cold start renders the sidebar and main content immediately — including on Linux tiling window managers — instead of leaving them invisible or blank until a restart.
|
||||
|
||||
### Square corners — a sharper, boxier look
|
||||
|
||||
- New **Square Corners** toggle under **Settings → Appearance → Visual Options → Display** strips the rounded corners off cards and cover art across the app — handy when a theme's rounding does not suit your album covers. Off by default; buttons, inputs, and dialogs keep the theme's corners.
|
||||
|
||||
## Improved
|
||||
|
||||
- With **Remember EQ per device** on and **System Default** selected, the equalizer now keys profiles to the active OS default output and switches when that default changes outside the app (Windows sound settings, Stream Deck, PipeWire / `wpctl`, and similar). **Linux:** when PipeWire has already moved the stream to the new default, the device watcher skips a redundant reopen to avoid a post-switch stutter. **Windows:** release builds no longer freeze on the loading splash; audio output devices use stable backend IDs with clearer labels, and device-change detection works again after upgrade.
|
||||
|
||||
## Fixed
|
||||
|
||||
### Playback and audio
|
||||
|
||||
- Playing a song from a playlist no longer shows the track's own cover in Now Playing when the album page would show album art — Now Playing consistently uses the album cover.
|
||||
- ReplayGain applies when stream or queue metadata resolves late; gapless auto-advance no longer leaves the playbar on the previous track.
|
||||
- Pausing a large queue behind a reverse proxy (e.g. Nginx) no longer snaps playback back to an earlier track — Navidrome saves via POST when supported, and a failed save no longer lets idle auto-pull overwrite your queue.
|
||||
- Internet Radio equalizer presets and slider changes now apply to live stations — not just library tracks.
|
||||
|
||||
### Offline, Now Playing, and Navidrome
|
||||
|
||||
- Desktop builds no longer get stuck showing "offline" when WebKitGTK leaves `navigator.onLine` at `false` while the server is reachable — the app confirms with a real server probe instead.
|
||||
- When browsing offline, Artists, Albums, Tracks, and Genres list only content with on-disk bytes — pins, favorites-auto saves, and hot-cache playback — instead of the full server or local index catalog.
|
||||
|
||||
### Themes and integrations
|
||||
|
||||
- Connecting a scrobble service in **Settings → Integrations → Music Network** now shows the underlying error alongside the generic network message, so a bad URL or rejected token is easier to tell apart from a reachability problem on your machine.
|
||||
- Horizontal album rails in themes with drop shadows no longer clip card shadows at the edges — scroll arrows keep working without theme authors overriding rail overflow.
|
||||
|
||||
### Browse and library
|
||||
|
||||
- Adding tracks to a playlist no longer fails past ~341 songs — writes go to the server in batches, and large-playlist edits are faster.
|
||||
- Queue rows far from the playing track no longer stay stuck on a "…" placeholder — the queue loads details for whatever you scroll to, in the desktop panel, mobile drawer, and fullscreen **Up next** overlay.
|
||||
- The year filter on **All Albums** no longer clamps on every keystroke while you type a four-digit year — it commits on blur, Enter, or outside click.
|
||||
- Starring an album on the detail page fills the heart immediately and keeps it filled after reload; album-level stars and ratings reconcile consistently across browse and Favorites.
|
||||
- Renamed artists no longer linger as ghost entries that open to "Artist not found"; album artist links and cover tiles in **Random Albums** stay consistent after resync.
|
||||
- Custom playlist and internet radio covers uploaded in Navidrome show again on cards and detail headers.
|
||||
- Sorting albums by artist now follows the name shown on each row — featured-guest releases no longer land under the wrong artist in **Artist / Year** order.
|
||||
|
||||
### Other
|
||||
|
||||
- Servers behind a custom HTTP header gate (Cloudflare Access, Pangolin, and similar) now work for the full app — add-server errors stay on the form with a clear reason, browse and detail views load natively behind the gate, streaming and covers carry the header reliably, and returning to a LAN address upgrades the connection automatically when both LAN and public endpoints are configured.
|
||||
- Modal dialogs now announce their title to screen readers when they open.
|
||||
- **Windows:** **Who is listening?** no longer shows `psysonic/undefined` as the client id.
|
||||
|
||||
|
||||
## [1.49.0]
|
||||
|
||||
## Highlights
|
||||
|
||||
### Play queue sync — pick up where you left off on another device
|
||||
|
||||
- Click the header connection indicator to **pull** the active server's play queue when it differs from yours; a yellow LED shows when browse and playback servers do not match.
|
||||
- While paused or stopped, **idle auto-pull** checks every 10 seconds and applies server changes when you have been still for 30+ seconds.
|
||||
- Queue **push** sends only tracks owned by the playback server, so mixed-server queues stay sane when you switch servers.
|
||||
- Local queue edits while paused are no longer overwritten by auto-pull; pressing **Play** pushes your changes immediately, and the sync LED no longer flashes on every track during normal playback.
|
||||
- After the last track ends with repeat off, idle pull no longer rewinds to an earlier server position — the queue stays where playback finished.
|
||||
|
||||
### AutoDJ — minimum pauses, maximum music
|
||||
|
||||
- New **AutoDJ** mode — a smart crossfade that blends tracks intelligently: it trims dead air, rides natural fades, and keeps handovers musical instead of abrupt. Its own button in the queue toolbar and its own entry under **Settings → Audio**, alongside Crossfade and Gapless — only one at a time. Off by default; classic **Crossfade** is unchanged.
|
||||
- **Smooth skip** (on by default with AutoDJ) crossfades manual Next/Previous and track picks from where you are listening instead of hard-cutting; the play/pause button pulses while a blend is active.
|
||||
- Cap how long overlaps may last: **Auto** (content-driven, up to 12 s) or **Limit** (slider 2–30 s) under **Settings → Audio → Track transitions**.
|
||||
- The last track in the queue plays through to the end instead of being trimmed when nothing follows.
|
||||
|
||||
### Playlist folders — your playlists, organised
|
||||
|
||||
- Folders on the **Playlists** page and in the sidebar keep long lists tidy — group by mood, occasion, or anything you like. Drag playlists in, rename and collapse folders, or choose **Move to folder** from the right-click menu. Switch back to a flat list whenever you prefer.
|
||||
|
||||
### Settings — tidier and easier to scan
|
||||
|
||||
- Settings are grouped into clear, labelled panels so related options sit together — less hunting around. The **Native Hi-Res Playback** option now explains in plain language what it actually does.
|
||||
- **Normalization** and **Track transitions** are now their own sections under **Settings → Audio**, and the queue options (display mode, toolbar, and Play-Next order) are gathered into one **Queue Settings** group under **Personalisation**.
|
||||
|
||||
### Japanese, Hungarian, and Polish — now in your language
|
||||
|
||||
- Psysonic is now available in **Japanese (日本語)**, **Hungarian (Magyar)**, and **Polish (Polski)** — pick any of them from the language menu on the **Settings** and **Login** screens.
|
||||
|
||||
### Theme store — spot updates, pick your style
|
||||
|
||||
- Version numbers on store themes and ones you have installed make it obvious when an update is ready.
|
||||
- Filter for **animated** or **static** themes only — less scrolling when you already know the look you want.
|
||||
|
||||
### Hi-Res playback — smoother transitions between sample rates
|
||||
|
||||
- Under **Settings → Audio → Native Hi-Res**, choose a **blend rate** (44.1 / 88.2 / 96 kHz) for crossfade, AutoDJ, and gapless when adjacent tracks differ in sample rate — mixed 88.2 ↔ 44.1 kHz handovers no longer tear mid-transition.
|
||||
|
||||
### Artist artwork — richer home, artist, and fullscreen views
|
||||
|
||||
- Switch on **External Artwork Scraper** under **Settings → Integrations** to pull artist imagery from fanart.tv: a wide backdrop on the fullscreen player, a banner across the top of the artist page, and now the artist's backdrop behind the home screen's **mainstage** too. Off by default, your Navidrome covers stay in charge, and turning it back off removes the fetched images again.
|
||||
- Choose which images each place uses as its background, and in what order — drag to reorder or switch a source off — right under the same setting. The mainstage also loads the next backdrops ahead of time so they appear without a blank gap.
|
||||
|
||||
### Equalizer — a profile per output device
|
||||
|
||||
- Turn on **Remember EQ per device** under **Settings → Audio** and Psysonic keeps a separate equalizer setup for each output — speakers, headphones, a USB DAC — and switches to the right one automatically when you change devices, including when **System Default** is selected and the OS default output changes outside the app. Off by default.
|
||||
|
||||
### Orbit — everyone hears transitions the host chose
|
||||
|
||||
- In a shared **Orbit** session, the host's crossfade, gapless, or AutoDJ settings — including length and smooth skip — apply to all guests until you leave. Transition controls in **Settings → Audio** and the queue toolbar show as host-controlled while you are a guest.
|
||||
|
||||
### Themes — follow your system's light and dark mode
|
||||
|
||||
- The theme scheduler can now match your **system's light/dark setting** instead of a fixed clock: pick a light theme and a dark one, and Psysonic switches along with your OS. Choose **Time of Day** or **System Theme** under **Settings → Themes** — the existing time-based schedule is still there.
|
||||
|
||||
### Servers behind a reverse proxy — custom HTTP headers
|
||||
|
||||
- Per-server **custom HTTP headers** in **Settings → Servers** for Cloudflare Access, Pangolin, and similar gates — applied to library sync, playback, covers, offline download, and the rest without putting secrets in invite links.
|
||||
|
||||
### Album details — every genre, not just the first
|
||||
|
||||
- Album details now show **all** the genres a release spans: the main genre appears inline with a **+N** chip that opens the full, clickable list, each genre linking to its own page. Genres combine album and track tags and read from the local library index, so they work offline too.
|
||||
|
||||
### Compact buttons — switch to icon-only controls
|
||||
|
||||
- New **Compact buttons** option under **Settings → Appearance** switches the action and toolbar buttons between large labelled buttons and small icon-only ones — across album, artist and playlist headers, the shared browse toolbars, and the Most Played controls. Defaults to large; on phones the album header keeps its large touch targets.
|
||||
|
||||
### Playlists — sort by date added
|
||||
|
||||
- Sort a playlist by **Date added** (newest or oldest first), or by title, artist, album and the other columns, from a new sort dropdown in the playlist toolbar. The Subsonic API has no per-track "added on" date, so this follows the playlist's own order — servers add new tracks at the end, so newest-first puts your latest additions on top.
|
||||
|
||||
## Improved
|
||||
|
||||
- **macOS:** the window's title bar now follows the active theme instead of the grey system bar; the native window buttons stay in place, floating over the themed bar.
|
||||
- Pressing **Play**, **Shuffle**, or **Add to queue** on a playlist starts playback without reloading the whole page with a spinner — editing the playlist still refreshes the list as before.
|
||||
- Dragging sidebar items in **Settings → Personalisation → Sidebar** (or long-pressing in the sidebar itself) keeps each item exactly where you release it — no snap-back or off-by-one landing.
|
||||
|
||||
## Fixed
|
||||
|
||||
### Playback and audio
|
||||
|
||||
- **Timeline** mode keeps your session play-history strip when you **Play** an album or playlist; the current track stays pinned at the top, and replaying a history row inserts after the playing track instead of replacing the queue.
|
||||
- **Opus/Ogg** tracks no longer fight the seekbar while they are still loading — scrub to where you want to be and keep listening.
|
||||
- The equalizer preset picker shows the active **AutoEQ** profile name again instead of going blank.
|
||||
|
||||
### Offline, Now Playing, and Navidrome
|
||||
|
||||
- The **Live** listener count in the header stays up to date even when the "Who is listening?" popover is closed.
|
||||
|
||||
### Browse and library
|
||||
|
||||
- Album and artist covers — and the full-size view when you click a cover — open at full resolution again instead of looking soft or small.
|
||||
- Albums sorted by artist now list each artist's work A–Z by title — no more random order within a name.
|
||||
- **Artist → Year** keeps artists grouped but walks through their albums chronologically, oldest first.
|
||||
- Genres with no remaining tracks disappear after you retag and resync the library, without restarting the app.
|
||||
- The **Artists** A–Z index matches Navidrome ignored articles — **The Beatles** lands under **B**, not **T**.
|
||||
- **All Albums → Only compilations** and **Favorites** return the albums you expect instead of an empty or partial list.
|
||||
|
||||
### Player and playlists
|
||||
|
||||
- **Add to playlist** from the player bar adds the song you are hearing, not the whole album.
|
||||
- On **Favorites**, bulk **Add to playlist** and **Play selected** / **Add selected to queue** act on every checked row.
|
||||
- **Play Now** on a playlist in the right-click menu starts playback instead of only opening the list.
|
||||
- Playlists page header buttons wrap on narrow windows instead of clipping off-screen when the queue panel is open.
|
||||
|
||||
### Other
|
||||
|
||||
- **Orbit** sessions stay reliable on long listens — guests keep receiving updates, radio no longer pollutes the shared queue, and opening Psysonic on a second device does not delete a live session elsewhere.
|
||||
- On the artist page, the header uses the fanart.tv background when no banner is available — the same image the fullscreen player already showed.
|
||||
- **Windows:** Previous, Play/Pause, and Next are back when you hover the taskbar icon — and Play/Pause shows whether music is playing or paused.
|
||||
- **macOS:** the dock icon matches native app sizing instead of looking oversized.
|
||||
- **Linux:** **Niri** is recognised as a tiling compositor and gets the same custom title bar behaviour as Hyprland and Sway; the "new version available" popup reads clearly on setups where the background blur used to bleed through.
|
||||
|
||||
## Under the hood
|
||||
|
||||
- If a screen hits an unexpected error, the app now shows a small recoverable card (**Try again** / **Reload app**) and keeps playing, instead of the whole window going blank.
|
||||
|
||||
|
||||
## [1.48.1]
|
||||
|
||||
## Fixed
|
||||
|
||||
### Playback and audio
|
||||
|
||||
- Changing tracks — skipping, or the automatic advance at the end of a song — no longer freezes the interface for a few seconds: the progress bar and lyrics keep updating, and on **Windows** a change of output device now takes effect right away.
|
||||
- Seeking an **Opus/Ogg** track — and then pressing **Stop** — no longer crashes the app.
|
||||
- **macOS:** pausing or stopping playback and then unplugging headphones (or switching the output device) no longer makes playback restart — it stays paused or stopped.
|
||||
|
||||
### Offline, Now Playing, and Navidrome
|
||||
|
||||
- On large **Navidrome** libraries, background library sync no longer locks up database writes for minutes at a time, so play history, ratings, and other saves go through without long delays.
|
||||
|
||||
### Themes and integrations
|
||||
|
||||
- **Discord** Rich Presence shows the album cover again when a server profile has both a local and a public address.
|
||||
|
||||
### Other
|
||||
|
||||
- **Windows:** the system media controls (Quick Settings media tile, lock screen, and third-party flyouts) now show the album cover and display **Psysonic** with its icon instead of "Unknown application".
|
||||
- **macOS:** closing the window with the red close button now respects **Minimize to Tray** — with it on, the window hides to the tray instead of quitting.
|
||||
|
||||
|
||||
|
||||
## [1.48.0]
|
||||
|
||||
## Highlights
|
||||
|
||||
### Offline listening
|
||||
|
||||
- When the server is unreachable, browse and detail pages show what you already have locally instead of empty errors — albums, artists, playlists, and cross-server favorites.
|
||||
- Starred tracks, pinned albums, and playlists live under one **media** folder; browse them in **Offline Library** and see disk usage at a glance.
|
||||
- **Favorites auto-sync** keeps loved songs on disk; pinned albums and playlists refresh when the library index updates.
|
||||
|
||||
### Music Network — scrobble beyond Last.fm
|
||||
|
||||
- **Settings → Integrations** now hosts a **Music Network**: connect **Last.fm**, **Libre.fm**, **ListenBrainz**, **Maloja**, **Rocksky**, **Koito**, or your own **GNU FM** instance — and scrobble to several at once.
|
||||
- Pick a **primary** service for loved tracks, similar artists, and stats; other connections still receive scrobbles. Your existing Last.fm setup migrates automatically.
|
||||
- A master switch turns the whole network on or off.
|
||||
|
||||
### Theme Store
|
||||
|
||||
- Browse and install community themes from **Settings → Themes** — search, dark/light filter, full-size previews, and sort by popularity or date.
|
||||
- Six palettes ship with the app; everything else installs on demand and works offline after the first download.
|
||||
- **Now Playing** follows every theme cleanly, including light palettes.
|
||||
- Import a theme from a local `.zip` when you have a package from a friend or your own design.
|
||||
- The sidebar nudges you when an installed theme has an update; one-click update from the theme card.
|
||||
|
||||
### Fullscreen player
|
||||
|
||||
- Rebuilt for much lower CPU and memory use: a calm, sharp fullscreen view with album art, waveform seekbar, up-next queue, synced lyrics, ratings, and a clock that follows your **Clock format** setting.
|
||||
- The song title no longer shows a leading track number, and descenders (g, j, p, q, y) are no longer clipped.
|
||||
|
||||
### Live — richer now playing on Navidrome 0.62+
|
||||
|
||||
- On servers with OpenSubsonic **playbackReport** (Navidrome ≥ 0.62), **Live** shows who is playing or paused, how far into the track they are, and playback speed when another client sends it — with smooth position updates between refreshes.
|
||||
- In **Who is listening?**, each listener shows a small status dot (playing, paused, or idle) instead of a vague “minutes ago” line.
|
||||
|
||||
### Queue — Timeline mode
|
||||
|
||||
- A third queue layout keeps the current track in the middle with history above and up next below — great for long listening sessions. Cycle the header control or pick it in **Settings → Personalisation → Queue display**.
|
||||
|
||||
### Settings → Servers
|
||||
|
||||
- Each card shows the server software and version (e.g. **Navidrome 0.62.0**) under the name, with a cleaner two-line layout and compact actions.
|
||||
- Navidrome **0.62+** shows a green **AudioMuse-AI** badge when the plugin is detected — no manual toggle on current Navidrome.
|
||||
|
||||
### Sidebar — pin Now Playing to the top
|
||||
|
||||
- New **Settings → Sidebar** toggle moves **Now Playing** to the top of the sidebar instead of the bottom (off by default).
|
||||
|
||||
### Startup
|
||||
|
||||
- A themed loading splash appears while the app starts — colours follow your active theme, including community palettes.
|
||||
|
||||
## Improved
|
||||
|
||||
- Audio decoding runs on **Symphonia 0.6**; streams start sooner and recover from stalls without restarting the player.
|
||||
- The **Preload Next Track** toggle under **Settings → Storage → Buffering** is gone — playback no longer waits on that extra RAM prefetch. Gapless, crossfade, and Hot Cache behave as before.
|
||||
- New **Semitones** playback-speed strategy (±12 st, 0.1 step) with two-decimal speed readout; optional fine steps in **Settings → Audio → Advanced**.
|
||||
|
||||
## Fixed
|
||||
|
||||
### Playback and audio
|
||||
|
||||
- **Windows:** the app no longer keeps the audio device open while idle, so the system can sleep when music is not playing.
|
||||
- **macOS:** steady playback stutter from background device polling is gone on the default output path.
|
||||
- After a long pause, the seekbar shows the saved position immediately and the next **Play** resumes without an audible blip at track start.
|
||||
- **Stop** keeps the real waveform on the seekbar instead of falling back to flat bars.
|
||||
|
||||
### Offline, Now Playing, and Navidrome
|
||||
|
||||
- Now Playing cards (**from this album**, discography, most played) stay populated during cached and offline playback instead of blanking out on track change.
|
||||
- Navidrome **Show in Now Playing** and play-count scrobbles work when audio plays from hot cache, offline pins, or auto-synced favorites.
|
||||
- Mixed-server queues still report to the correct Navidrome server.
|
||||
|
||||
### Themes and integrations
|
||||
|
||||
- Self-hosted Music Network targets (Koito, Maloja, custom GNU FM with a pasted token) scrobble again — reconnect once if you connected before this fix.
|
||||
- Favoriting from the player bar, fullscreen player, or shortcuts updates the star in track lists and playlists immediately.
|
||||
- Discord Rich Presence shows album art again when covers come from the server.
|
||||
- Focus rings and dropdown borders follow the active theme consistently.
|
||||
|
||||
### Browse and library
|
||||
|
||||
- Tracks tagged with several genres in one field (e.g. `Metal/Ambient/Experimental`) match **each genre** again in browse, filters, and search.
|
||||
- **All Albums → Only compilations** returns results for common tagging patterns.
|
||||
- Album grids show the album artist on compilations instead of a random track artist.
|
||||
- Song rails (**Random Picks**, **Discover Songs**, etc.) link each name in multi-artist credits separately.
|
||||
- **Artist → Top Tracks** play works even when the artist page has no albums loaded yet.
|
||||
- **Home → Most Played** no longer jumps the page when you load more albums.
|
||||
- **Mainstage** hero backdrop stays in sync when you skip albums quickly.
|
||||
|
||||
### Other
|
||||
|
||||
- **Linux:** the `curl | bash` auto-installer works again.
|
||||
- **Linux:** internet radio no longer appears twice in the desktop now-playing overlay.
|
||||
- On Navidrome **0.62+**, add/edit/delete radio stations is shown only to admin accounts; everyone can still play and favourite stations.
|
||||
- **Linux custom title bar:** pick window button styles (dots, flat, pill, and more) and optionally hide minimize in **Settings → Appearance**.
|
||||
- The active server card under **Settings → Servers** draws a complete border on all sides.
|
||||
|
||||
## Under the hood
|
||||
|
||||
- Navidrome **0.62+** auto-detects **AudioMuse-AI** and routes Instant Mix / Lucky Mix through the smarter API when the plugin is present — older Navidrome keeps the manual toggle you already know.
|
||||
|
After Width: | Height: | Size: 432 KiB |
@@ -1,36 +0,0 @@
|
||||
# Shell completion for `psysonic`
|
||||
|
||||
Covers global flags (`--help`, `--info`, …), `completions …`, and `--player` commands (`next`, `audio-device …`, `library …`, `mix …`, …). Run `psysonic --help` for the full list.
|
||||
|
||||
The same scripts are **embedded in the release binary**: run **`psysonic completions`** for install instructions, or **`psysonic completions bash` / `zsh`** to print the scripts (no repo checkout needed).
|
||||
|
||||
## zsh
|
||||
|
||||
Copy or symlink `_psysonic` into a directory on your `$fpath`, then reload completion:
|
||||
|
||||
```sh
|
||||
mkdir -p ~/.zsh/completions
|
||||
ln -sf /path/to/psysonic/completions/_psysonic ~/.zsh/completions/_psysonic
|
||||
fpath=(~/.zsh/completions $fpath)
|
||||
autoload -Uz compinit && compinit
|
||||
```
|
||||
|
||||
If you use a plugin manager, point its `fpath` at this repo’s `completions/` directory instead.
|
||||
|
||||
## bash
|
||||
|
||||
Source the script once (e.g. in `~/.bashrc`):
|
||||
|
||||
```sh
|
||||
source /path/to/psysonic/completions/psysonic.bash
|
||||
```
|
||||
|
||||
Use this only under **bash** (including macOS’s `/bin/bash` 3.2). **zsh** users should install `_psysonic` instead — do not `source` the `.bash` file in zsh.
|
||||
|
||||
## Device names after `audio-device set`
|
||||
|
||||
Completion can suggest IDs from `psysonic-cli-audio-devices.json` (same paths the app uses: `$XDG_RUNTIME_DIR` or `$TMPDIR`/`/tmp`). That file appears after you run **`psysonic --player audio-device list`** while the app is running. Optional: install **`jq`** for parsing that JSON in the completion scripts.
|
||||
|
||||
## Folder ids after `library set`
|
||||
|
||||
Same idea with **`psysonic-cli-library.json`**, produced by **`psysonic --player library list`**. Optional **`jq`** for completion of folder ids plus the literal **`all`**.
|
||||
@@ -1,157 +0,0 @@
|
||||
#compdef psysonic
|
||||
# Zsh completion for Psysonic CLI (see `psysonic --help`).
|
||||
|
||||
_psysonic_audio_device_json() {
|
||||
local f
|
||||
if [[ -n $XDG_RUNTIME_DIR ]]; then
|
||||
f="$XDG_RUNTIME_DIR/psysonic-cli-audio-devices.json"
|
||||
[[ -r $f ]] && { print -r -- "$f"; return }
|
||||
fi
|
||||
f="${TMPDIR:-/tmp}/psysonic-cli-audio-devices.json"
|
||||
[[ -r $f ]] && print -r -- "$f"
|
||||
}
|
||||
|
||||
_psysonic_audio_device_ids() {
|
||||
command -v jq &>/dev/null || return
|
||||
local jf
|
||||
jf="$(_psysonic_audio_device_json)" || return
|
||||
local -a devs
|
||||
devs=( ${(@f)"$(jq -r '.devices[]? | select(type == "string")' "$jf" 2>/dev/null)"} )
|
||||
(( $#devs )) && compadd -a devs
|
||||
}
|
||||
|
||||
_psysonic_library_json() {
|
||||
local f
|
||||
if [[ -n $XDG_RUNTIME_DIR ]]; then
|
||||
f="$XDG_RUNTIME_DIR/psysonic-cli-library.json"
|
||||
[[ -r $f ]] && { print -r -- "$f"; return }
|
||||
fi
|
||||
f="${TMPDIR:-/tmp}/psysonic-cli-library.json"
|
||||
[[ -r $f ]] && print -r -- "$f"
|
||||
}
|
||||
|
||||
_psysonic_library_folder_ids() {
|
||||
command -v jq &>/dev/null || return
|
||||
local jf
|
||||
jf="$(_psysonic_library_json)" || return
|
||||
local -a ids
|
||||
ids=( ${(@f)"$(jq -r '.folders[]? | select(.id != null) | .id | tostring' "$jf" 2>/dev/null)"} )
|
||||
(( $#ids )) && compadd -a ids
|
||||
}
|
||||
|
||||
_psysonic_snapshot_json() {
|
||||
local f
|
||||
if [[ -n $XDG_RUNTIME_DIR ]]; then
|
||||
f="$XDG_RUNTIME_DIR/psysonic-cli-snapshot.json"
|
||||
[[ -r $f ]] && { print -r -- "$f"; return }
|
||||
fi
|
||||
f="${TMPDIR:-/tmp}/psysonic-cli-snapshot.json"
|
||||
[[ -r $f ]] && print -r -- "$f"
|
||||
}
|
||||
|
||||
_psysonic_server_ids() {
|
||||
command -v jq &>/dev/null || return
|
||||
local jf
|
||||
jf="$(_psysonic_snapshot_json)" || return
|
||||
local -a ids
|
||||
ids=( ${(@f)"$(jq -r '.servers[]? | select(.id != null) | .id | tostring' "$jf" 2>/dev/null)"} )
|
||||
(( $#ids )) && compadd -a ids
|
||||
}
|
||||
|
||||
_psysonic_globals() {
|
||||
compadd -J options -X 'option' -- \
|
||||
--help --version --info --json --quiet --logs --player completions
|
||||
}
|
||||
|
||||
integer i pidx=0
|
||||
for (( i = 2; i < CURRENT; i++ )); do
|
||||
[[ ${words[i]} == --player ]] && pidx=i
|
||||
done
|
||||
|
||||
if [[ ${words[CURRENT-1]} == --tail ]]; then
|
||||
_message -e descriptions 'number of lines'
|
||||
return
|
||||
fi
|
||||
|
||||
if (( pidx == 0 )); then
|
||||
local has_logs=0
|
||||
for (( i = 2; i < CURRENT; i++ )); do
|
||||
[[ ${words[i]} == --logs ]] && has_logs=1
|
||||
done
|
||||
if (( CURRENT == 3 )) && [[ ${words[2]} == completions ]]; then
|
||||
compadd help bash zsh
|
||||
return
|
||||
fi
|
||||
if (( has_logs )); then
|
||||
compadd -- --tail -f --follow
|
||||
else
|
||||
_psysonic_globals
|
||||
fi
|
||||
return
|
||||
fi
|
||||
|
||||
local -a sub
|
||||
if (( pidx + 1 <= CURRENT - 1 )); then
|
||||
sub=( ${words[pidx + 1,CURRENT - 1]} )
|
||||
else
|
||||
sub=()
|
||||
fi
|
||||
|
||||
integer n=${#sub[@]}
|
||||
if (( n == 0 )); then
|
||||
compadd -J verbs -X 'player command' -- \
|
||||
next prev play pause stop seek volume shuffle repeat mute unmute star unstar rating reload \
|
||||
audio-device library server search mix
|
||||
return
|
||||
fi
|
||||
|
||||
case ${sub[1]} in
|
||||
audio-device)
|
||||
if (( n == 1 )); then
|
||||
compadd list set
|
||||
elif [[ ${sub[2]} == set ]] && (( n == 2 )); then
|
||||
compadd default
|
||||
_psysonic_audio_device_ids
|
||||
fi
|
||||
;;
|
||||
library)
|
||||
if (( n == 1 )); then
|
||||
compadd list set
|
||||
elif [[ ${sub[2]} == set ]] && (( n == 2 )); then
|
||||
compadd all
|
||||
_psysonic_library_folder_ids
|
||||
fi
|
||||
;;
|
||||
mix)
|
||||
(( n == 1 )) && compadd append new
|
||||
;;
|
||||
server)
|
||||
if (( n == 1 )); then
|
||||
compadd list set
|
||||
elif [[ ${sub[2]} == set ]] && (( n == 2 )); then
|
||||
_psysonic_server_ids
|
||||
fi
|
||||
;;
|
||||
search)
|
||||
if (( n == 1 )); then
|
||||
compadd track album artist
|
||||
elif (( n >= 2 )); then
|
||||
_message -e descriptions 'search query'
|
||||
fi
|
||||
;;
|
||||
repeat)
|
||||
(( n == 1 )) && compadd off all one
|
||||
;;
|
||||
rating)
|
||||
(( n == 1 )) && compadd 0 1 2 3 4 5
|
||||
;;
|
||||
seek)
|
||||
(( n == 1 )) && _message -e descriptions 'integer delta (seconds, e.g. -5)'
|
||||
;;
|
||||
volume)
|
||||
(( n == 1 )) && _message -e descriptions 'percent 0–100, or ±N for relative change'
|
||||
;;
|
||||
play)
|
||||
(( n == 1 )) && _message -e descriptions 'Subsonic id (song, album, or artist)'
|
||||
;;
|
||||
esac
|
||||
@@ -1,188 +0,0 @@
|
||||
# bash completion for Psysonic (see `psysonic --help`).
|
||||
# Install: source /path/to/completions/psysonic.bash
|
||||
# Optional: jq + prior `psysonic --player audio-device list` for device name completion.
|
||||
#
|
||||
# Uses no `mapfile` so bash 3.2 (macOS default) works.
|
||||
#
|
||||
# compopt is bash-only (programmable completion). Guard so sourcing this file
|
||||
# under zsh or plain sh does not print "command not found: compopt".
|
||||
|
||||
_psysonic_compopt() {
|
||||
command -v compopt &>/dev/null || return 0
|
||||
compopt "$@" 2>/dev/null || return 0
|
||||
}
|
||||
|
||||
_psysonic_compreply_from_compgen() {
|
||||
# $1 = compgen -W word list, $2 = current word
|
||||
COMPREPLY=()
|
||||
local line
|
||||
while IFS= read -r line; do
|
||||
[[ -n $line ]] && COMPREPLY+=("$line")
|
||||
done < <(compgen -W "$1" -- "$2")
|
||||
}
|
||||
|
||||
_psysonic_audio_device_json() {
|
||||
local f
|
||||
if [[ -n ${XDG_RUNTIME_DIR:-} ]]; then
|
||||
f="$XDG_RUNTIME_DIR/psysonic-cli-audio-devices.json"
|
||||
[[ -r $f ]] && { printf '%s' "$f"; return; }
|
||||
fi
|
||||
f="${TMPDIR:-/tmp}/psysonic-cli-audio-devices.json"
|
||||
[[ -r $f ]] && printf '%s' "$f"
|
||||
}
|
||||
|
||||
_psysonic_library_json() {
|
||||
local f
|
||||
if [[ -n ${XDG_RUNTIME_DIR:-} ]]; then
|
||||
f="$XDG_RUNTIME_DIR/psysonic-cli-library.json"
|
||||
[[ -r $f ]] && { printf '%s' "$f"; return; }
|
||||
fi
|
||||
f="${TMPDIR:-/tmp}/psysonic-cli-library.json"
|
||||
[[ -r $f ]] && printf '%s' "$f"
|
||||
}
|
||||
|
||||
_psysonic_snapshot_json() {
|
||||
local f
|
||||
if [[ -n ${XDG_RUNTIME_DIR:-} ]]; then
|
||||
f="$XDG_RUNTIME_DIR/psysonic-cli-snapshot.json"
|
||||
[[ -r $f ]] && { printf '%s' "$f"; return; }
|
||||
fi
|
||||
f="${TMPDIR:-/tmp}/psysonic-cli-snapshot.json"
|
||||
[[ -r $f ]] && printf '%s' "$f"
|
||||
}
|
||||
|
||||
_psysonic_complete() {
|
||||
local cur
|
||||
cur="${COMP_WORDS[COMP_CWORD]}"
|
||||
local prev=""
|
||||
(( COMP_CWORD > 0 )) && prev="${COMP_WORDS[COMP_CWORD-1]}"
|
||||
|
||||
if [[ $prev == --tail ]]; then
|
||||
_psysonic_compopt -o default
|
||||
COMPREPLY=()
|
||||
return
|
||||
fi
|
||||
|
||||
local i pidx=0
|
||||
for (( i = 1; i < COMP_CWORD; i++ )); do
|
||||
[[ ${COMP_WORDS[i]} == --player ]] && pidx=$i
|
||||
done
|
||||
|
||||
if (( pidx == 0 )); then
|
||||
local has_logs=0
|
||||
for (( i = 1; i < COMP_CWORD; i++ )); do
|
||||
[[ ${COMP_WORDS[i]} == --logs ]] && has_logs=1
|
||||
done
|
||||
if [[ ${COMP_WORDS[1]} == completions && COMP_CWORD -eq 2 ]]; then
|
||||
_psysonic_compreply_from_compgen 'help bash zsh' "$cur"
|
||||
return
|
||||
fi
|
||||
if (( has_logs )); then
|
||||
_psysonic_compreply_from_compgen '--tail -f --follow' "$cur"
|
||||
else
|
||||
_psysonic_compreply_from_compgen '--help --version --info --json --quiet --logs --player completions' "$cur"
|
||||
fi
|
||||
return
|
||||
fi
|
||||
|
||||
local -a sub=()
|
||||
for (( i = pidx + 1; i < COMP_CWORD; i++ )); do
|
||||
sub+=("${COMP_WORDS[i]}")
|
||||
done
|
||||
local n=${#sub[@]}
|
||||
|
||||
if (( n == 0 )); then
|
||||
_psysonic_compreply_from_compgen 'next prev play pause stop seek volume shuffle repeat mute unmute star unstar rating reload audio-device library server search mix' "$cur"
|
||||
return
|
||||
fi
|
||||
|
||||
case ${sub[0]} in
|
||||
audio-device)
|
||||
if (( n == 1 )); then
|
||||
_psysonic_compreply_from_compgen 'list set' "$cur"
|
||||
elif [[ ${sub[1]} == set ]] && (( n == 2 )); then
|
||||
COMPREPLY=()
|
||||
local jf d
|
||||
jf="$(_psysonic_audio_device_json)"
|
||||
if [[ -n $jf ]] && command -v jq &>/dev/null; then
|
||||
while IFS= read -r d; do
|
||||
[[ -n $d && $d == "$cur"* ]] && COMPREPLY+=("$d")
|
||||
done < <(jq -r '.devices[]? | select(type == "string")' "$jf" 2>/dev/null)
|
||||
fi
|
||||
while IFS= read -r line; do
|
||||
[[ -n $line ]] && COMPREPLY+=("$line")
|
||||
done < <(compgen -W 'default' -- "$cur")
|
||||
((${#COMPREPLY[@]})) && _psysonic_compopt -o filenames
|
||||
fi
|
||||
;;
|
||||
library)
|
||||
if (( n == 1 )); then
|
||||
_psysonic_compreply_from_compgen 'list set' "$cur"
|
||||
elif [[ ${sub[1]} == set ]] && (( n == 2 )); then
|
||||
COMPREPLY=()
|
||||
local jf id line
|
||||
jf="$(_psysonic_library_json)"
|
||||
if [[ -n $jf ]] && command -v jq &>/dev/null; then
|
||||
while IFS= read -r id; do
|
||||
[[ -n $id && $id == "$cur"* ]] && COMPREPLY+=("$id")
|
||||
done < <(jq -r '.folders[]? | select(.id != null) | .id | tostring' "$jf" 2>/dev/null)
|
||||
fi
|
||||
while IFS= read -r line; do
|
||||
[[ -n $line ]] && COMPREPLY+=("$line")
|
||||
done < <(compgen -W 'all' -- "$cur")
|
||||
((${#COMPREPLY[@]})) && _psysonic_compopt -o filenames
|
||||
fi
|
||||
;;
|
||||
mix)
|
||||
(( n == 1 )) && _psysonic_compreply_from_compgen 'append new' "$cur"
|
||||
;;
|
||||
server)
|
||||
if (( n == 1 )); then
|
||||
_psysonic_compreply_from_compgen 'list set' "$cur"
|
||||
elif [[ ${sub[1]} == set ]] && (( n == 2 )); then
|
||||
COMPREPLY=()
|
||||
local jf sid
|
||||
jf="$(_psysonic_snapshot_json)"
|
||||
if [[ -n $jf ]] && command -v jq &>/dev/null; then
|
||||
while IFS= read -r sid; do
|
||||
[[ -n $sid && $sid == "$cur"* ]] && COMPREPLY+=("$sid")
|
||||
done < <(jq -r '.servers[]? | select(.id != null) | .id | tostring' "$jf" 2>/dev/null)
|
||||
fi
|
||||
((${#COMPREPLY[@]})) && _psysonic_compopt -o filenames
|
||||
fi
|
||||
;;
|
||||
search)
|
||||
if (( n == 1 )); then
|
||||
_psysonic_compreply_from_compgen 'track album artist' "$cur"
|
||||
elif (( n >= 2 )); then
|
||||
_psysonic_compopt -o default
|
||||
COMPREPLY=()
|
||||
fi
|
||||
;;
|
||||
repeat)
|
||||
(( n == 1 )) && _psysonic_compreply_from_compgen 'off all one' "$cur"
|
||||
;;
|
||||
rating)
|
||||
(( n == 1 )) && _psysonic_compreply_from_compgen '0 1 2 3 4 5' "$cur"
|
||||
;;
|
||||
seek)
|
||||
if (( n == 1 )); then
|
||||
_psysonic_compopt -o default
|
||||
COMPREPLY=()
|
||||
fi
|
||||
;;
|
||||
volume)
|
||||
if (( n == 1 )); then
|
||||
_psysonic_compreply_from_compgen '+5 +10 -5 -10 50' "$cur"
|
||||
fi
|
||||
;;
|
||||
play)
|
||||
if (( n == 1 )); then
|
||||
_psysonic_compopt -o default
|
||||
COMPREPLY=()
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
complete -F _psysonic_complete psysonic
|
||||
@@ -1,45 +0,0 @@
|
||||
import eslint from '@eslint/js';
|
||||
import globals from 'globals';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
// `scripts/` (Node CI helpers) is intentionally ignored — this config targets the browser `src/` tree.
|
||||
{ ignores: ['dist', 'coverage', 'src-tauri', 'research', 'scripts'] },
|
||||
// This gradual baseline deliberately omits the React Compiler rules (set-state-in-effect, refs,
|
||||
// immutability, …) that the strict config enables. The per-line `eslint-disable-next-line` directives
|
||||
// those rules require therefore read as "unused" here, so unused-directive reporting is turned off for
|
||||
// this config only — the strict config keeps the default reporting and stays 0/0.
|
||||
{ linterOptions: { reportUnusedDisableDirectives: 'off' } },
|
||||
eslint.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
'react-hooks/rules-of-hooks': 'error',
|
||||
'react-hooks/exhaustive-deps': 'warn',
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -1,45 +0,0 @@
|
||||
import eslint from '@eslint/js';
|
||||
import globals from 'globals';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
// `scripts/` (Node CI helpers) is intentionally ignored — this config targets the
|
||||
// browser `src/` tree. See `npm run lint:scripts` if scripts ever need a Node-globals lint pass.
|
||||
// `src/generated` holds machine-generated output (release-notes bundle,
|
||||
// tauri-specta `bindings.ts`) — not linted. `bindings.ts` is still type-checked
|
||||
// by tsc (the FE↔BE contract); its generated runtime helper uses `any`.
|
||||
{ ignores: ['dist', 'coverage', 'src-tauri', 'research', 'scripts', 'src/generated'] },
|
||||
eslint.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/no-explicit-any': 'error',
|
||||
// Promote deps to error at strict stage (wave 6+)
|
||||
'react-hooks/exhaustive-deps': 'error',
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1783776592,
|
||||
"narHash": "sha256-UgCQzxeWI75XM8G+hPrPh+MKzEPjG3SpAj7dtqSbksA=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "e7a3ca8092b61ff85b6a45bf863ea2b2d6a661b3",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
{
|
||||
description = ''
|
||||
Psysonic for NixOS / nixpkgs: installable app + dev shell.
|
||||
|
||||
Packages:
|
||||
nix build .#psysonic # or .#default — desktop app; GDK follows session (no wrapper pin)
|
||||
nix build .#psysonic-gdk-session # same derivation (back-compat alias); see nixos-install.md
|
||||
nix build .#psysonic-x11-legacy # legacy: GDK_BACKEND=x11 wrapper (old default)
|
||||
nix profile install .#psysonic
|
||||
|
||||
Run (after build, or from any clone with flake):
|
||||
nix run .#psysonic
|
||||
nix run .#psysonic-gdk-session # identical to psysonic
|
||||
nix run .#psysonic-x11-legacy # GDK x11 pinned (former default wrap)
|
||||
nix run github:Psychotoxical/psysonic
|
||||
|
||||
Development:
|
||||
nix develop # mkShell (Rust/Node/WebKit deps); same GDK idea as installable (no GDK pin)
|
||||
nix shell .#devShells.default # same environment without entering subshell semantics
|
||||
Local cargo output: .build-local/ (gitignored; not copied into flake source tarball)
|
||||
|
||||
Release pipeline updates `flake.lock` (nixpkgs pin refresh) and
|
||||
`nix/upstream-sources.json` (npmDepsHash) on every `v*` tag push —
|
||||
see `.github/workflows/release.yml` (verify-nix job). Package version
|
||||
is read from `package.json`; nothing in this file needs manual bumping
|
||||
per release.
|
||||
'';
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
};
|
||||
|
||||
outputs =
|
||||
{ self, nixpkgs }:
|
||||
let
|
||||
inherit (nixpkgs) lib;
|
||||
systems = [
|
||||
"x86_64-linux"
|
||||
"aarch64-linux"
|
||||
];
|
||||
forSystem = f: lib.genAttrs systems f;
|
||||
|
||||
mkShellFor =
|
||||
system:
|
||||
let
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
gstPlugins = with pkgs.gst_all_1; [
|
||||
gstreamer
|
||||
gst-plugins-base
|
||||
gst-plugins-good
|
||||
gst-plugins-bad
|
||||
];
|
||||
gstPluginPath = pkgs.lib.makeSearchPath "lib/gstreamer-1.0" gstPlugins;
|
||||
in
|
||||
pkgs.mkShell {
|
||||
packages = with pkgs; [
|
||||
nodejs_22
|
||||
rustc
|
||||
cargo
|
||||
clippy
|
||||
cargo-llvm-cov
|
||||
llvmPackages.llvm
|
||||
jq
|
||||
cmake
|
||||
pkg-config
|
||||
openssl
|
||||
gtk3
|
||||
webkitgtk_4_1
|
||||
libsoup_3
|
||||
glib-networking
|
||||
atk
|
||||
cairo
|
||||
gdk-pixbuf
|
||||
glib
|
||||
pango
|
||||
librsvg
|
||||
alsa-lib
|
||||
libayatana-appindicator
|
||||
]
|
||||
++ gstPlugins;
|
||||
|
||||
shellHook = ''
|
||||
_repo="$(git rev-parse --show-toplevel 2>/dev/null || true)"
|
||||
if [ -n "$_repo" ] && [ -f "$_repo/flake.nix" ]; then
|
||||
export CARGO_TARGET_DIR="''${CARGO_TARGET_DIR:-$_repo/.build-local/cargo-target}"
|
||||
fi
|
||||
export LD_LIBRARY_PATH="${pkgs.libayatana-appindicator}/lib''${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
|
||||
export GST_PLUGIN_PATH="${gstPluginPath}''${GST_PLUGIN_PATH:+:$GST_PLUGIN_PATH}"
|
||||
export GIO_EXTRA_MODULES="${pkgs.glib-networking}/lib/gio/modules''${GIO_EXTRA_MODULES:+:$GIO_EXTRA_MODULES}"
|
||||
export LLVM_COV="${pkgs.llvmPackages.llvm}/bin/llvm-cov"
|
||||
export LLVM_PROFDATA="${pkgs.llvmPackages.llvm}/bin/llvm-profdata"
|
||||
unset CI
|
||||
'';
|
||||
|
||||
OPENSSL_LIB_DIR = "${pkgs.openssl.out}/lib";
|
||||
OPENSSL_INCLUDE_DIR = "${pkgs.openssl.dev}/include";
|
||||
};
|
||||
|
||||
upstreamMeta = lib.importJSON ./nix/upstream-sources.json;
|
||||
|
||||
psysonicFor =
|
||||
system:
|
||||
nixpkgs.legacyPackages.${system}.callPackage ./nix/psysonic.nix {
|
||||
src = self;
|
||||
inherit upstreamMeta;
|
||||
};
|
||||
|
||||
# Same app with GDK_BACKEND pinned to X11 — previous default wrapper behaviour (see nixos-install.md).
|
||||
psysonicX11LegacyFor =
|
||||
system:
|
||||
nixpkgs.legacyPackages.${system}.callPackage ./nix/psysonic.nix {
|
||||
src = self;
|
||||
inherit upstreamMeta;
|
||||
forceGdkX11 = true;
|
||||
};
|
||||
|
||||
in
|
||||
{
|
||||
devShells = forSystem (system: { default = mkShellFor system; });
|
||||
|
||||
packages = forSystem (
|
||||
system:
|
||||
let
|
||||
p = psysonicFor system;
|
||||
pX11 = psysonicX11LegacyFor system;
|
||||
in
|
||||
{
|
||||
psysonic = p;
|
||||
psysonic-gdk-session = p;
|
||||
psysonic-x11-legacy = pX11;
|
||||
default = p;
|
||||
}
|
||||
);
|
||||
|
||||
apps = forSystem (
|
||||
system:
|
||||
let
|
||||
p = psysonicFor system;
|
||||
pX11 = psysonicX11LegacyFor system;
|
||||
in
|
||||
{
|
||||
default = {
|
||||
type = "app";
|
||||
program = lib.getExe p;
|
||||
meta = {
|
||||
inherit (p.meta) description homepage license;
|
||||
mainProgram = "psysonic";
|
||||
};
|
||||
};
|
||||
psysonic-gdk-session = {
|
||||
type = "app";
|
||||
program = lib.getExe p;
|
||||
meta = {
|
||||
inherit (p.meta) description homepage license;
|
||||
mainProgram = "psysonic";
|
||||
};
|
||||
};
|
||||
psysonic-x11-legacy = {
|
||||
type = "app";
|
||||
program = lib.getExe pX11;
|
||||
meta = {
|
||||
inherit (pX11.meta) description homepage license;
|
||||
mainProgram = "psysonic";
|
||||
};
|
||||
};
|
||||
}
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -6,88 +6,12 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="Psysonic – Dein Navidrome Desktop Player" />
|
||||
<title>Psysonic</title>
|
||||
<script src="/startup-splash-preflight.js"></script>
|
||||
<style>
|
||||
html, body {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
background: var(--startup-splash-bg, #1e1e2e);
|
||||
}
|
||||
#root.app-root--startup-pending {
|
||||
visibility: hidden;
|
||||
}
|
||||
#app-startup-splash {
|
||||
--splash-bg: var(--startup-splash-bg, #1e1e2e);
|
||||
--splash-text: var(--startup-splash-text, #cdd6f4);
|
||||
--splash-muted: var(--startup-splash-muted, #a6adc8);
|
||||
--splash-accent: var(--startup-splash-accent, #cba6f7);
|
||||
--splash-track: var(--startup-splash-track, #313244);
|
||||
--splash-logo-start: var(--startup-splash-logo-start, var(--splash-accent));
|
||||
--splash-logo-end: var(--startup-splash-logo-end, var(--splash-accent));
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2147483646;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1.75rem;
|
||||
background: var(--splash-bg);
|
||||
color: var(--splash-text);
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
#app-startup-splash .app-startup-splash__logo {
|
||||
height: 72px;
|
||||
width: auto;
|
||||
opacity: 0.95;
|
||||
}
|
||||
#app-startup-splash .app-startup-splash__label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--splash-muted);
|
||||
}
|
||||
#app-startup-splash .app-startup-splash__bar {
|
||||
width: min(240px, 72vw);
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: var(--splash-track);
|
||||
overflow: hidden;
|
||||
}
|
||||
#app-startup-splash .app-startup-splash__bar-fill {
|
||||
width: 42%;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--splash-accent);
|
||||
animation: app-startup-splash-indeterminate 1.15s ease-in-out infinite;
|
||||
}
|
||||
@keyframes app-startup-splash-indeterminate {
|
||||
0% { transform: translateX(-120%); }
|
||||
100% { transform: translateX(320%); }
|
||||
}
|
||||
</style>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app-startup-splash" role="status" aria-live="polite" aria-label="Loading Psysonic">
|
||||
<svg class="app-startup-splash__logo" viewBox="0 0 115.549 130.30972" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="startupSplashLogoGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="var(--splash-logo-start)" />
|
||||
<stop offset="100%" stop-color="var(--splash-logo-end)" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g transform="translate(220.53237,27.789086)">
|
||||
<path fill="url(#startupSplashLogoGrad)" d="m -191.83501,87.581279 v -14.93937 l 1.01946,-0.029 c 1.8496,-0.0526 5.09881,-2.007 6.98453,-4.20123 2.13731,-2.48697 3.28384,-4.43657 4.52545,-7.69521 0.51751,-1.35819 1.078,-2.78694 1.24554,-3.175 0.16755,-0.38805 0.88173,-2.7693 1.58707,-5.29166 0.70533,-2.52236 1.41605,-4.90361 1.57937,-5.29167 0.16441,-0.39067 0.30759,11.85061 0.32081,27.42847 l 0.0239,28.134031 h -8.64306 -8.64305 z m -3.42317,-19.65031 c -0.81559,-0.16111 -1.84746,-0.48272 -2.29306,-0.71468 -1.09242,-0.5687 -2.72853,-2.16884 -2.74064,-2.68038 -0.005,-0.22765 -0.38465,-0.86265 -0.84281,-1.41111 -0.8626,-1.03264 -2.38323,-4.66133 -4.63113,-11.05137 -1.72997,-4.91772 -1.63358,-4.68451 -3.35352,-8.11389 -0.82714,-1.64924 -1.91998,-3.45186 -2.42853,-4.00582 -1.28805,-1.40307 -4.41406,-2.7715 -6.89485,-3.01827 l -2.08965,-0.20785 1.43221,-0.99035 c 1.5468,-1.06957 5.31147,-2.35399 6.9124,-2.35835 1.72563,-0.005 4.25283,0.7809 5.71247,1.77575 1.63175,1.11217 3.92377,3.83335 3.77488,4.48172 -0.0559,0.24344 0.11427,0.44261 0.37817,0.44261 0.53171,0 3.78445,6.24176 3.78445,7.26208 0,0.15195 0.30609,0.92171 0.6802,1.71057 0.37412,0.78887 1.08633,2.44854 1.5827,3.68817 1.00279,2.50434 2.57055,5.33152 2.95544,5.32962 0.85183,-0.004 3.83204,-7.97894 5.40479,-14.46266 1.9193,-7.91232 5.01161,-18.44694 6.10967,-20.81389 2.30114,-4.96024 4.60601,-7.03734 8.12223,-7.31959 1.95377,-0.15683 2.44243,-0.0601 4.01261,0.79453 2.49546,1.35819 3.31044,2.35029 5.40102,6.57479 0.93741,1.89425 3.29625,9.1126 4.36446,13.35583 0.51289,2.03729 1.21262,4.57729 1.55498,5.64444 0.34236,1.06716 0.83543,2.65466 1.09573,3.52778 0.96371,3.23267 3.75139,8.2344 5.51689,9.89856 2.09506,1.9748 4.10606,3.2977 5.85136,3.84922 0.72761,0.22993 1.32292,0.49404 1.32292,0.58692 0,0.0929 -0.71641,0.48577 -1.59202,0.87309 -2.29705,1.01609 -6.48839,1.02714 -8.75823,0.0231 -3.42674,-1.51581 -6.17101,-4.45149 -8.36088,-8.94406 -0.59782,-1.22642 -1.23412,-2.50231 -1.41401,-2.8353 -0.17988,-0.333 -0.47718,-1.20612 -0.66066,-1.94028 -0.74987,-3.00045 -6.42415,-19.25706 -6.99617,-20.04376 -0.79895,-1.09881 -0.87818,-1.08476 -1.55823,0.27628 -1.1693,2.3402 -2.07427,5.18987 -3.61302,11.37709 -3.03871,12.21839 -6.36478,22.38234 -8.0081,24.47148 -0.36655,0.466 -0.66646,0.99153 -0.66646,1.16785 0,0.86017 -2.61454,3.05174 -4.28395,3.59089 -1.94625,0.62857 -2.53141,0.65417 -4.78366,0.20926 z m 49.82815,-13.29265 c -2.77991,-0.70614 -6.29714,-6.05076 -8.15323,-12.38927 -0.30389,-1.03778 -0.47868,-1.96073 -0.38841,-2.051 0.0903,-0.0903 1.5695,-0.22877 3.28719,-0.30779 8.47079,-0.38969 9.78292,-0.63406 14.05919,-2.61837 3.78653,-1.75706 9.09259,-6.79386 10.56941,-10.03304 3.78708,-8.30644 4.33485,-14.20262 2.08448,-22.4376404 -1.15336,-4.22063002 -3.6401,-8.21361 -6.73205,-10.80969 -1.12271,-0.94265 -2.12066,-1.8146 -2.21767,-1.93765 -0.3794,-0.48123 -4.30858,-2.4333296 -6.41876,-3.1889796 -2.16778,-0.77628 -2.64336,-0.79956 -18.71666,-0.91597 l -16.49236,-0.11945 V -0.68605142 10.798429 h -0.8256 c -1.53109,0 -5.09758,2.09614 -6.79456,3.99338 -1.65639,1.85186 -4.54446,7.43871 -5.41264,10.47051 -0.25002,0.87312 -0.58222,1.98437 -0.73823,2.46944 -0.39136,1.2169 -2.0765,7.30176 -3.12634,11.28889 -0.2052,0.7793 -0.33685,-11.27627 -0.35693,-32.6846104 l -0.0318,-33.9193396 1.55319,-0.12371 c 0.85426,-0.068 12.32395,-0.10028 25.4882,-0.0716 20.69377,0.045 24.2694,0.12953 26.40444,0.62402 3.9887,0.92382 7.58472,2.04932 7.58472,2.3739 0,0.16576 0.52886,0.30139 1.17524,0.30139 2.09331,0 10.76432,4.87704 10.22435,5.75072 -0.12186,0.19718 -0.0447,0.24734 0.17328,0.11263 0.60692,-0.3751 4.21691,3.0333 6.9953,6.60467 2.06429,2.6534496 4.63504,8.4775396 5.94174,13.4611396 1.7681,6.7433 1.74625,15.8657704 -0.0549,22.9305504 -2.11084,8.27937 -4.97852,13.41407 -10.75456,19.25647 -2.59968,2.62955 -8.78375,7.02548 -9.88326,7.02548 -0.27557,0 -0.68644,0.1854 -0.91304,0.412 -0.39593,0.39593 -0.78905,0.56749 -4.31522,1.88319 -3.68968,1.37672 -10.83412,2.28545 -13.21446,1.68081 z m 7.57002,-15.26489 c 0,-0.19403 -0.07,-0.35278 -0.15557,-0.35278 -0.0856,0 -0.25368,0.15875 -0.3736,0.35278 -0.11992,0.19403 -0.0499,0.35278 0.15557,0.35278 0.20548,0 0.3736,-0.15875 0.3736,-0.35278 z" />
|
||||
</g>
|
||||
</svg>
|
||||
<div class="app-startup-splash__bar" aria-hidden="true">
|
||||
<div class="app-startup-splash__bar-fill"></div>
|
||||
</div>
|
||||
<span class="app-startup-splash__label">Loading</span>
|
||||
</div>
|
||||
<div id="root" class="app-root--startup-pending"></div>
|
||||
<script src="/startup-splash-reveal.js"></script>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 32 KiB |
@@ -1,184 +0,0 @@
|
||||
# Installable Psysonic (Tauri): npm build → cargo tauri build --no-bundle.
|
||||
# Source: `self` (this repo). Package version is read from package.json.
|
||||
# `npmDepsHash` in nix/upstream-sources.json is refreshed by the release
|
||||
# workflow (see .github/workflows/release.yml, verify-nix job).
|
||||
|
||||
{
|
||||
lib,
|
||||
stdenv,
|
||||
fetchNpmDeps,
|
||||
npmHooks,
|
||||
rustPlatform,
|
||||
cargo,
|
||||
rustc,
|
||||
pkg-config,
|
||||
cmake,
|
||||
openssl,
|
||||
gtk3,
|
||||
webkitgtk_4_1,
|
||||
libsoup_3,
|
||||
glib-networking,
|
||||
alsa-lib,
|
||||
libayatana-appindicator,
|
||||
atk,
|
||||
cairo,
|
||||
gdk-pixbuf,
|
||||
glib,
|
||||
pango,
|
||||
librsvg,
|
||||
cargo-tauri,
|
||||
nodejs,
|
||||
makeWrapper,
|
||||
wrapGAppsHook4,
|
||||
copyDesktopItems,
|
||||
makeDesktopItem,
|
||||
gst_all_1,
|
||||
src,
|
||||
upstreamMeta,
|
||||
# When true, wrapProgram sets GDK_BACKEND=x11 (legacy conservative stack).
|
||||
# When false (default), only lib paths — GDK follows session; binary applies webkit2gtk-nvidia-quirk only.
|
||||
forceGdkX11 ? false,
|
||||
}:
|
||||
|
||||
let
|
||||
version = (lib.importJSON (src + "/package.json")).version;
|
||||
# WebKit media stack needs discoverable GStreamer plugins (e.g. appsink in gst-plugins-base).
|
||||
gstPlugins = with gst_all_1; [
|
||||
gstreamer
|
||||
gst-plugins-base
|
||||
gst-plugins-good
|
||||
gst-plugins-bad
|
||||
];
|
||||
gstPluginPath = lib.makeSearchPath "lib/gstreamer-1.0" gstPlugins;
|
||||
srcClean = lib.cleanSourceWith {
|
||||
inherit src;
|
||||
filter =
|
||||
path: _:
|
||||
let
|
||||
f = toString path;
|
||||
in
|
||||
!(lib.hasInfix "/node_modules/" f)
|
||||
&& !(lib.hasInfix "/dist/" f)
|
||||
&& !(lib.hasInfix "/target/" f)
|
||||
&& !(lib.hasInfix "/.git/" f)
|
||||
&& !(lib.hasInfix "/result/" f)
|
||||
&& !(lib.hasInfix "/.flatpak-builder/" f)
|
||||
&& !(lib.hasInfix "/.build-local/" f);
|
||||
};
|
||||
npmDeps = fetchNpmDeps {
|
||||
src = srcClean;
|
||||
hash = upstreamMeta.npmDepsHash;
|
||||
};
|
||||
cargoLockFile = src + "/src-tauri/Cargo.lock";
|
||||
in
|
||||
|
||||
stdenv.mkDerivation (finalAttrs: {
|
||||
pname = "psysonic";
|
||||
inherit version;
|
||||
src = srcClean;
|
||||
inherit npmDeps;
|
||||
|
||||
strictDeps = true;
|
||||
|
||||
# cmake is only for Rust deps (e.g. libopus); no top-level CMakeLists.txt in repo root
|
||||
dontUseCmakeConfigure = true;
|
||||
|
||||
nativeBuildInputs = [
|
||||
npmHooks.npmConfigHook
|
||||
cargo
|
||||
rustc
|
||||
rustPlatform.cargoSetupHook
|
||||
pkg-config
|
||||
cmake
|
||||
makeWrapper
|
||||
wrapGAppsHook4
|
||||
copyDesktopItems
|
||||
cargo-tauri
|
||||
nodejs
|
||||
];
|
||||
|
||||
buildInputs = [
|
||||
gtk3
|
||||
webkitgtk_4_1
|
||||
libsoup_3
|
||||
glib-networking
|
||||
openssl
|
||||
alsa-lib
|
||||
libayatana-appindicator
|
||||
atk
|
||||
cairo
|
||||
gdk-pixbuf
|
||||
glib
|
||||
pango
|
||||
librsvg
|
||||
]
|
||||
++ gstPlugins;
|
||||
|
||||
cargoRoot = "src-tauri";
|
||||
cargoDeps = rustPlatform.importCargoLock {
|
||||
lockFile = cargoLockFile;
|
||||
# Local path overrides for `[patch.crates-io]` entries in src-tauri/Cargo.toml.
|
||||
# Keep in sync with that block — importCargoLock needs the source to match
|
||||
# the lockfile entries for patched crates (otherwise it tries to fetch from
|
||||
# crates.io and the hash mismatches).
|
||||
outputHashes = { };
|
||||
};
|
||||
|
||||
dontUseCargoParallelJobs = true;
|
||||
|
||||
env = {
|
||||
OPENSSL_DIR = "${openssl.dev}";
|
||||
OPENSSL_LIB_DIR = "${openssl.out}/lib";
|
||||
OPENSSL_INCLUDE_DIR = "${openssl.dev}/include";
|
||||
VITE_LASTFM_API_KEY = "";
|
||||
VITE_LASTFM_API_SECRET = "";
|
||||
};
|
||||
|
||||
# beforeBuildCommand runs npm run build; npmConfigHook supplies offline node_modules
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
export HOME=$(mktemp -d)
|
||||
(cd src-tauri && cargo tauri build --no-bundle)
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
install -Dm755 src-tauri/target/release/psysonic -t $out/bin
|
||||
install -Dm644 src-tauri/icons/128x128.png $out/share/icons/hicolor/128x128/apps/psysonic.png
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
desktopItems = [
|
||||
(makeDesktopItem {
|
||||
name = "psysonic";
|
||||
desktopName = "Psysonic";
|
||||
comment = "Subsonic-compatible music player";
|
||||
icon = "psysonic";
|
||||
exec = "psysonic";
|
||||
categories = [ "AudioVideo" "Audio" "Player" ];
|
||||
})
|
||||
];
|
||||
|
||||
postFixup =
|
||||
let
|
||||
gdkX11Wrap = lib.optionalString forceGdkX11 ''
|
||||
--set GDK_BACKEND x11
|
||||
'';
|
||||
in
|
||||
''
|
||||
wrapProgram $out/bin/psysonic \
|
||||
--prefix LD_LIBRARY_PATH : "${lib.makeLibraryPath [ libayatana-appindicator ]}" \
|
||||
--prefix GST_PLUGIN_PATH : "${gstPluginPath}" \
|
||||
--prefix GIO_EXTRA_MODULES : "${glib-networking}/lib/gio/modules" \
|
||||
${gdkX11Wrap}
|
||||
'';
|
||||
|
||||
meta = {
|
||||
description = "Desktop music player for Subsonic-compatible servers";
|
||||
homepage = "https://github.com/Psychotoxical/psysonic";
|
||||
license = lib.licenses.gpl3Only;
|
||||
mainProgram = "psysonic";
|
||||
platforms = lib.platforms.linux;
|
||||
};
|
||||
})
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"npmDepsHash": "sha256-r7Pe7YRJvRhigg4vmfLbnLdWr5EKGAeAC53vbs3V2uw="
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
# Installing Psysonic on NixOS (flake)
|
||||
|
||||
This guide is for **NixOS** users who want **Psysonic from the upstream Git flake** (`github:Psychotoxical/psysonic`). Supported systems match the flake: **`x86_64-linux`** and **`aarch64-linux`**.
|
||||
|
||||
**Stability:** The project is in **very active development**. For **production or everyday use**, prefer **released builds**: pin the flake input to a stable **`app-v*`** tag, or track the **`release`** branch (`?ref=release`). Following **`main`** or **`next`** is better suited to contributors and early testers.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
**Flakes** enabled (e.g. in `configuration.nix`):
|
||||
|
||||
```nix
|
||||
nix.settings.experimental-features = [ "nix-command" "flakes" ];
|
||||
```
|
||||
|
||||
## Binary cache (Cachix)
|
||||
|
||||
The project publishes store paths to a public Cachix cache so you can **substitute** binaries instead of compiling Psysonic locally on every machine.
|
||||
|
||||
- **Cache page:** [psysonic.cachix.org](https://psysonic.cachix.org)
|
||||
- **Substituter URL:** `https://psysonic.cachix.org`
|
||||
- **Public key** (trust this only if it matches what you expect from the cache owners):
|
||||
|
||||
```text
|
||||
psysonic.cachix.org-1:M9cQyQ7tgvUWOQ5Pyt8ozlMoPLtOZir6MfRuTH9/VYA=
|
||||
```
|
||||
|
||||
### NixOS (`configuration.nix` or a flake module)
|
||||
|
||||
Add the substituter **and** its signing key under `nix.settings`. Keep `cache.nixos.org` in the list so ordinary `nixpkgs` binaries still resolve:
|
||||
|
||||
```nix
|
||||
{
|
||||
nix.settings = {
|
||||
substituters = [
|
||||
"https://psysonic.cachix.org"
|
||||
"https://cache.nixos.org/"
|
||||
];
|
||||
trusted-public-keys = [
|
||||
"psysonic.cachix.org-1:M9cQyQ7tgvUWOQ5Pyt8ozlMoPLtOZir6MfRuTH9/VYA="
|
||||
"cache.nixos.org-1:6NCHdSuAYQQOxGEKTGXLN9WWRXoSBT8GRiSnR6IdfGW="
|
||||
];
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
After `nixos-rebuild switch`, builds that hit the cache will download from Cachix. More background: [Cachix — Getting started](https://docs.cachix.org/getting-started).
|
||||
|
||||
## Install on NixOS (flake configuration)
|
||||
|
||||
Add the repo as an **input**, then reference **`packages.<system>.psysonic`** (or **`default`**, which is the same package).
|
||||
|
||||
### Example: top-level `flake.nix` + `nixosConfigurations`
|
||||
|
||||
```nix
|
||||
{
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
psysonic.url = "github:Psychotoxical/psysonic";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, ... }@inputs: let
|
||||
system = "x86_64-linux";
|
||||
in {
|
||||
nixosConfigurations.my-host = nixpkgs.lib.nixosSystem {
|
||||
inherit system;
|
||||
modules = [
|
||||
./configuration.nix
|
||||
{
|
||||
environment.systemPackages = [
|
||||
inputs.psysonic.packages.${system}.psysonic
|
||||
];
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Inside a **module** where you already have `pkgs` and flake `inputs` in scope, a common pattern is:
|
||||
|
||||
```nix
|
||||
environment.systemPackages = with pkgs; [
|
||||
# …
|
||||
inputs.psysonic.packages.${pkgs.stdenv.hostPlatform.system}.psysonic
|
||||
];
|
||||
```
|
||||
|
||||
### Linux wrapper (default vs legacy X11)
|
||||
|
||||
The flake exposes **three** Linux attributes (two are the **same derivation**):
|
||||
|
||||
| Flake attribute | Wrapper behaviour |
|
||||
|----------------|-------------------|
|
||||
| **`psysonic`**, **`default`**, **`psysonic-gdk-session`** | Wrappers prefix **libraries only** (**GStreamer**, **AppIndicator**); **`GDK_BACKEND`** is **not** pinned. The binary invokes **`webkit2gtk-nvidia-quirk`** early on Linux (unless **`PSYSONIC_WEBKIT_GPU_ACCEL`** is set); no extra **`WEBKIT_DISABLE_*`** heuristics in **`main.rs`**. Override with **`GDK_BACKEND`**, **`WEBKIT_DISABLE_*`**, etc. whenever you want. |
|
||||
| **`psysonic-x11-legacy`** | Former default: **`GDK_BACKEND=x11`** pinned in the wrapper. Use if you relied on **XWayland-ish** stability on messy stacks. Same binary as **`psysonic`**. |
|
||||
|
||||
`psysonic-gdk-session` remains a **back-compat alias** for **`psysonic`** (identical store path).
|
||||
|
||||
### Example: legacy X11 wrap
|
||||
|
||||
```nix
|
||||
inputs.psysonic.packages.${system}.psysonic-x11-legacy
|
||||
```
|
||||
|
||||
Or one-shot (quote the URL in **zsh** — `?` / `#` are special):
|
||||
|
||||
```bash
|
||||
nix run 'github:Psychotoxical/psysonic#psysonic-x11-legacy' -- --help
|
||||
```
|
||||
|
||||
### Pinning a revision, branch, or tag
|
||||
|
||||
- **`main`** (default in the examples above) follows upstream development.
|
||||
- **Channel branches** (`next`, `release`) exist for pre-release / release automation. For **operational installs**, prefer **`release`** (or an **`app-v*`** tag) over **`next`** or **`main`**; use **`?ref=next`** only if you want pre-release channel builds.
|
||||
|
||||
```nix
|
||||
psysonic.url = "github:Psychotoxical/psysonic?ref=release";
|
||||
```
|
||||
|
||||
- **Tags** (`app-v*`) match published GitHub releases and are the usual choice for a **reproducible** install aligned with a shipped version:
|
||||
|
||||
```nix
|
||||
psysonic.url = "github:Psychotoxical/psysonic?ref=app-v1.44.0"; # example; pick a tag that exists on GitHub
|
||||
```
|
||||
|
||||
Use a `ref` (branch, tag, or commit SHA) that exists on GitHub.
|
||||
|
||||
### How `flake.lock` and `nix/upstream-sources.json` stay in sync
|
||||
|
||||
CI runs a **verify-nix** job (Nix build, `npmDepsHash` refresh, `flake.lock` refresh, Cachix push) from **`.github/workflows/reusable-channel-publish.yml`**, invoked by:
|
||||
|
||||
- **`.github/workflows/next.yml`** (Next channel, branch `next`)
|
||||
- **`.github/workflows/release.yml`** (Release channel, branch `release`)
|
||||
|
||||
So the lock and **`nix/upstream-sources.json`** (`npmDepsHash`) are updated as part of channel publishing, not only from a single legacy “tag-only” path. On **`main`**, **`nix-npm-deps-hash-sync.yml`** can also open PRs when `package-lock.json` changes so the Nix npm hash does not drift.
|
||||
|
||||
End users who pin **`main`** should run `nix flake update psysonic` (or equivalent) periodically if they want the latest lock inputs from upstream.
|
||||
|
||||
### One-shot run (no system install)
|
||||
|
||||
From any machine with flakes:
|
||||
|
||||
```bash
|
||||
nix run 'github:Psychotoxical/psysonic'
|
||||
```
|
||||
|
||||
Same as `nix build` / `packages.<system>.default` (session-native **GDK**); uses the flake `apps` output. For an **X11-pinned** launcher (old default), use `'github:Psychotoxical/psysonic#psysonic-x11-legacy'` (see [Linux wrapper](#linux-wrapper-default-vs-legacy-x11) above). `psysonic-gdk-session` is an **alias**—same as **`psysonic`**. With a branch pin, keep the **whole** `github:…?ref=…#…` string in **single quotes** under **zsh**.
|
||||
|
||||
### Apply configuration
|
||||
|
||||
- **NixOS flake host**
|
||||
|
||||
```bash
|
||||
sudo nixos-rebuild switch --flake .#my-host
|
||||
```
|
||||
|
||||
- **Home Manager** (if used separately)
|
||||
|
||||
```bash
|
||||
home-manager switch --flake .#my-user@my-host
|
||||
```
|
||||
|
||||
## Home Manager
|
||||
|
||||
If you manage packages with [Home Manager](https://github.com/nix-community/home-manager), add the same package to `home.packages`:
|
||||
|
||||
```nix
|
||||
home.packages = [
|
||||
inputs.psysonic.packages.${pkgs.stdenv.hostPlatform.system}.psysonic
|
||||
];
|
||||
```
|
||||
|
||||
(Adjust how `inputs` / `pkgs` are passed into your Home Manager module.)
|
||||
|
||||
## Development shell (contributors)
|
||||
|
||||
From a **flake-enabled** clone of the repo:
|
||||
|
||||
- **`nix develop`** — enters the upstream `devShell` (Rust, Node 22, WebKitGTK, GStreamer plugins for the webview, env hooks aligned with `package.json` / Tauri dev).
|
||||
- **`nix shell .#devShells.default`** — same packages and hooks without `nix develop`’s subshell semantics.
|
||||
|
||||
The flake **`devShell`** uses the same **`nixpkgs`** input as **`packages.psysonic`** (see **`flake.nix`**).
|
||||
|
||||
## Desktop entry
|
||||
|
||||
The flake package installs a **`.desktop`** file and icon via `copyDesktopItems`; after `nixos-rebuild switch` (or a Home Manager activation that includes the package), Psysonic should appear in your application launcher like any other desktop app.
|
||||
|
||||
## Troubleshooting (Linux / WebKit)
|
||||
|
||||
Some GPU / compositor setups show a black window or broken scrolling under Wayland/EGL. The upstream Help / FAQ documents workarounds (e.g. running under **X11** and compositor-related env vars). Those apply to the Nix-built binary as well as other Linux builds.
|
||||
|
||||
## More detail in-repo
|
||||
|
||||
- **`flake.nix`** — `packages`, `apps`, `devShells`, supported systems; inline comments for `nix build` / `nix develop` / `nix run`.
|
||||
- **`nix/psysonic.nix`** — how the app is built from this source tree (`npmDepsHash` from **`nix/upstream-sources.json`**).
|
||||
- **`.github/workflows/reusable-channel-publish.yml`** — **`verify-nix`** job (prefetch npm deps hash, `nix flake update`, `nix build .#psysonic`, Cachix push, optional lock refresh PR).
|
||||
- **`.github/workflows/next.yml`** / **`.github/workflows/release.yml`** — channel workflows that call the reusable publish workflow with **`verify_nix: true`**.
|
||||
- **`.github/workflows/nix-npm-deps-hash-sync.yml`** — keeps **`nix/upstream-sources.json`** aligned with **`package-lock.json`** on **`main`** via PRs.
|
||||
|
||||
For the full promotion and release picture (branches, tags, automation), see **`RELEASE_PROCESS.md`**.
|
||||
@@ -1,89 +1,43 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.51.0-dev",
|
||||
"version": "0.1.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"check:css-imports": "node scripts/check-css-import-graph.mjs",
|
||||
"prebuild:release-notes": "node scripts/generate-release-notes-bundle.mjs",
|
||||
"dev": "npm run prebuild:release-notes && vite",
|
||||
"build": "npm run prebuild:release-notes && tsc && vite build",
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"tauri:dev": "npm run prebuild:release-notes && tauri dev --config src-tauri/tauri.dev.conf.json",
|
||||
"tauri:build": "npm run prebuild:release-notes && node scripts/sync-wix-bundle-version.mjs && tauri build",
|
||||
"lint": "eslint -c eslint.config.mjs src",
|
||||
"lint:gradual": "eslint -c eslint.config.gradual.mjs src",
|
||||
"dep:check": "depcruise src --config .dependency-cruiser.cjs --ignore-known",
|
||||
"dep:check:barrels": "node scripts/check-feature-barrel-ui.mjs",
|
||||
"check:boot-chunks": "node scripts/check-boot-chunk-lucide.mjs",
|
||||
"build:verify": "npm run build && npm run check:boot-chunks",
|
||||
"test": "npm run prebuild:release-notes && vitest run && node --test scripts/extract-release-section.test.mjs scripts/wix-bundle-version.test.mjs scripts/sync-version-pipeline.test.mjs && npm run check:css-imports && npm run dep:check:barrels",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "npm run prebuild:release-notes && vitest run --coverage && npm run check:css-imports"
|
||||
"tauri:dev": "GDK_BACKEND=x11 WEBKIT_DISABLE_COMPOSITING_MODE=1 tauri dev",
|
||||
"tauri:build": "tauri build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/dm-sans": "^5.2.8",
|
||||
"@fontsource-variable/figtree": "^5.2.10",
|
||||
"@fontsource-variable/geist": "^5.2.8",
|
||||
"@fontsource-variable/golos-text": "^5.2.8",
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
"@fontsource-variable/lexend": "^5.2.11",
|
||||
"@fontsource-variable/manrope": "^5.2.8",
|
||||
"@fontsource-variable/nunito": "^5.2.7",
|
||||
"@fontsource-variable/outfit": "^5.2.8",
|
||||
"@fontsource-variable/plus-jakarta-sans": "^5.2.8",
|
||||
"@fontsource-variable/rubik": "^5.2.8",
|
||||
"@fontsource-variable/space-grotesk": "^5.2.10",
|
||||
"@fontsource-variable/unbounded": "^5.2.8",
|
||||
"@fontsource/opendyslexic": "^5.2.5",
|
||||
"@tanstack/react-virtual": "^3.13.26",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-fs": "^2.5.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-fs": "^2.4.5",
|
||||
"@tauri-apps/plugin-global-shortcut": "^2",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-notification": "^2",
|
||||
"@tauri-apps/plugin-shell": "^2",
|
||||
"@tauri-apps/plugin-store": "^2",
|
||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
"@tauri-apps/plugin-window-state": "^2.4.1",
|
||||
"axios": "^1.18.1",
|
||||
"i18next": "^26.0.8",
|
||||
"lucide-react": "^1.17.0",
|
||||
"axios": "^1.7.7",
|
||||
"howler": "^2.2.4",
|
||||
"i18next": "^25.8.16",
|
||||
"lucide-react": "^0.462.0",
|
||||
"md5": "^2.3.0",
|
||||
"papaparse": "^5.5.3",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-i18next": "^17.0.6",
|
||||
"react-router-dom": "^7.16.0",
|
||||
"zustand": "^5.0.14"
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^16.5.6",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tauri-apps/cli": "^2.11.2",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/md5": "^2.3.6",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/papaparse": "^5.5.2",
|
||||
"@types/react": "^19.2.15",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"@vitest/coverage-v8": "^4.1.8",
|
||||
"dependency-cruiser": "^18.0.0",
|
||||
"esbuild": "^0.28.1",
|
||||
"eslint": "^10.5.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.3",
|
||||
"globals": "^17.7.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.62.0",
|
||||
"vite": "^8.0.14",
|
||||
"vitest": "^4.1.8"
|
||||
},
|
||||
"overrides": {
|
||||
"undici": "^7.28.0"
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@types/howler": "^2.2.12",
|
||||
"@types/md5": "^2.3.5",
|
||||
"@types/node": "^25.3.5",
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
src/
|
||||
pkg/
|
||||
*.tar.zst
|
||||
*.tar.gz
|
||||
@@ -1,78 +0,0 @@
|
||||
# Maintainer: Psychotoxic <psychotoxic@gmx.de>
|
||||
pkgname=psysonic
|
||||
pkgver=1.50.0
|
||||
pkgrel=1
|
||||
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
|
||||
arch=('x86_64')
|
||||
url="https://github.com/Psychotoxical/psysonic"
|
||||
license=('GPL-3.0-only')
|
||||
depends=(
|
||||
'webkit2gtk-4.1'
|
||||
'gtk3'
|
||||
'openssl'
|
||||
'alsa-lib'
|
||||
)
|
||||
makedepends=(
|
||||
'npm'
|
||||
'rust'
|
||||
'cargo'
|
||||
'clang'
|
||||
'nasm'
|
||||
'cmake'
|
||||
)
|
||||
source=("$pkgname-$pkgver.tar.gz::https://github.com/Psychotoxical/psysonic/archive/refs/tags/app-v$pkgver.tar.gz")
|
||||
sha256sums=('SKIP')
|
||||
|
||||
build() {
|
||||
cd "psysonic-app-v$pkgver"
|
||||
|
||||
export CARGO_HOME="$srcdir/cargo-home"
|
||||
export npm_config_cache="$srcdir/npm-cache"
|
||||
|
||||
# ring (used by reqwest → rustls-tls) ships C/asm objects whose
|
||||
# symbols (ring_core_*) lld cannot resolve. On Arch/CachyOS, -fuse-ld=lld
|
||||
# is hardcoded into rustc itself (not just makepkg.conf RUSTFLAGS), so a
|
||||
# string substitution is a no-op. Appending -C link-arg=-fuse-ld=bfd works
|
||||
# because the last -fuse-ld=* flag passed to cc wins.
|
||||
export RUSTFLAGS="${RUSTFLAGS} -C link-arg=-fuse-ld=bfd"
|
||||
|
||||
# CachyOS sets -flto=auto in CFLAGS. ring compiles its C/asm objects via the
|
||||
# cc crate and picks up CFLAGS, producing fat-LTO objects. bfd cannot resolve
|
||||
# symbols from fat-LTO objects when linking against non-LTO Rust rlibs, causing
|
||||
# "undefined reference to ring_core_*" even though the symbols exist in the .a.
|
||||
# Strip CFLAGS/CXXFLAGS entirely so ring builds plain ELF objects.
|
||||
unset CFLAGS CXXFLAGS
|
||||
|
||||
npm install
|
||||
npm run tauri:build -- --no-bundle
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "psysonic-app-v$pkgver"
|
||||
|
||||
# Binary (in /usr/lib to make room for the wrapper)
|
||||
install -Dm755 "src-tauri/target/release/psysonic" "$pkgdir/usr/lib/psysonic/psysonic"
|
||||
|
||||
# Wrapper: thin exec (path hygiene only); GDK/session + WebKit mitigations come from main.rs / quirk (no GDK pin).
|
||||
install -Dm755 /dev/stdin "$pkgdir/usr/bin/psysonic" <<EOF
|
||||
#!/bin/sh
|
||||
exec /usr/lib/psysonic/psysonic "\$@"
|
||||
EOF
|
||||
|
||||
# Desktop entry
|
||||
install -Dm644 /dev/stdin "$pkgdir/usr/share/applications/psysonic.desktop" <<EOF
|
||||
[Desktop Entry]
|
||||
Name=Psysonic
|
||||
Comment=Desktop music player for Subsonic API-compatible servers
|
||||
Exec=psysonic
|
||||
Icon=psysonic
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Categories=AudioVideo;Audio;Music;Player;
|
||||
EOF
|
||||
|
||||
# Icons
|
||||
install -Dm644 "src-tauri/icons/32x32.png" "$pkgdir/usr/share/icons/hicolor/32x32/apps/psysonic.png"
|
||||
install -Dm644 "src-tauri/icons/128x128.png" "$pkgdir/usr/share/icons/hicolor/128x128/apps/psysonic.png"
|
||||
install -Dm644 "src-tauri/icons/128x128@2x.png" "$pkgdir/usr/share/icons/hicolor/256x256/apps/psysonic.png"
|
||||
}
|
||||
|
Before Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 70 KiB |
@@ -1,41 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="115.549mm"
|
||||
height="130.30972mm"
|
||||
viewBox="0 0 115.549 130.30972"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
xml:space="preserve"
|
||||
inkscape:export-filename="p-small.svg"
|
||||
inkscape:export-xdpi="96"
|
||||
inkscape:export-ydpi="96"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
|
||||
id="namedview1"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:document-units="mm"><inkscape:page
|
||||
x="0"
|
||||
y="0"
|
||||
width="115.549"
|
||||
height="130.30972"
|
||||
id="page2"
|
||||
margin="0"
|
||||
bleed="0" /></sodipodi:namedview><defs
|
||||
id="defs1" /><g
|
||||
inkscape:label="Ebene 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(220.53237,27.789086)"><path
|
||||
style="fill:#ffffff"
|
||||
d="m -191.83501,87.581279 v -14.93937 l 1.01946,-0.029 c 1.8496,-0.0526 5.09881,-2.007 6.98453,-4.20123 2.13731,-2.48697 3.28384,-4.43657 4.52545,-7.69521 0.51751,-1.35819 1.078,-2.78694 1.24554,-3.175 0.16755,-0.38805 0.88173,-2.7693 1.58707,-5.29166 0.70533,-2.52236 1.41605,-4.90361 1.57937,-5.29167 0.16441,-0.39067 0.30759,11.85061 0.32081,27.42847 l 0.0239,28.134031 h -8.64306 -8.64305 z m -3.42317,-19.65031 c -0.81559,-0.16111 -1.84746,-0.48272 -2.29306,-0.71468 -1.09242,-0.5687 -2.72853,-2.16884 -2.74064,-2.68038 -0.005,-0.22765 -0.38465,-0.86265 -0.84281,-1.41111 -0.8626,-1.03264 -2.38323,-4.66133 -4.63113,-11.05137 -1.72997,-4.91772 -1.63358,-4.68451 -3.35352,-8.11389 -0.82714,-1.64924 -1.91998,-3.45186 -2.42853,-4.00582 -1.28805,-1.40307 -4.41406,-2.7715 -6.89485,-3.01827 l -2.08965,-0.20785 1.43221,-0.99035 c 1.5468,-1.06957 5.31147,-2.35399 6.9124,-2.35835 1.72563,-0.005 4.25283,0.7809 5.71247,1.77575 1.63175,1.11217 3.92377,3.83335 3.77488,4.48172 -0.0559,0.24344 0.11427,0.44261 0.37817,0.44261 0.53171,0 3.78445,6.24176 3.78445,7.26208 0,0.15195 0.30609,0.92171 0.6802,1.71057 0.37412,0.78887 1.08633,2.44854 1.5827,3.68817 1.00279,2.50434 2.57055,5.33152 2.95544,5.32962 0.85183,-0.004 3.83204,-7.97894 5.40479,-14.46266 1.9193,-7.91232 5.01161,-18.44694 6.10967,-20.81389 2.30114,-4.96024 4.60601,-7.03734 8.12223,-7.31959 1.95377,-0.15683 2.44243,-0.0601 4.01261,0.79453 2.49546,1.35819 3.31044,2.35029 5.40102,6.57479 0.93741,1.89425 3.29625,9.1126 4.36446,13.35583 0.51289,2.03729 1.21262,4.57729 1.55498,5.64444 0.34236,1.06716 0.83543,2.65466 1.09573,3.52778 0.96371,3.23267 3.75139,8.2344 5.51689,9.89856 2.09506,1.9748 4.10606,3.2977 5.85136,3.84922 0.72761,0.22993 1.32292,0.49404 1.32292,0.58692 0,0.0929 -0.71641,0.48577 -1.59202,0.87309 -2.29705,1.01609 -6.48839,1.02714 -8.75823,0.0231 -3.42674,-1.51581 -6.17101,-4.45149 -8.36088,-8.94406 -0.59782,-1.22642 -1.23412,-2.50231 -1.41401,-2.8353 -0.17988,-0.333 -0.47718,-1.20612 -0.66066,-1.94028 -0.74987,-3.00045 -6.42415,-19.25706 -6.99617,-20.04376 -0.79895,-1.09881 -0.87818,-1.08476 -1.55823,0.27628 -1.1693,2.3402 -2.07427,5.18987 -3.61302,11.37709 -3.03871,12.21839 -6.36478,22.38234 -8.0081,24.47148 -0.36655,0.466 -0.66646,0.99153 -0.66646,1.16785 0,0.86017 -2.61454,3.05174 -4.28395,3.59089 -1.94625,0.62857 -2.53141,0.65417 -4.78366,0.20926 z m 49.82815,-13.29265 c -2.77991,-0.70614 -6.29714,-6.05076 -8.15323,-12.38927 -0.30389,-1.03778 -0.47868,-1.96073 -0.38841,-2.051 0.0903,-0.0903 1.5695,-0.22877 3.28719,-0.30779 8.47079,-0.38969 9.78292,-0.63406 14.05919,-2.61837 3.78653,-1.75706 9.09259,-6.79386 10.56941,-10.03304 3.78708,-8.30644 4.33485,-14.20262 2.08448,-22.4376404 -1.15336,-4.22063002 -3.6401,-8.21361 -6.73205,-10.80969 -1.12271,-0.94265 -2.12066,-1.8146 -2.21767,-1.93765 -0.3794,-0.48123 -4.30858,-2.4333296 -6.41876,-3.1889796 -2.16778,-0.77628 -2.64336,-0.79956 -18.71666,-0.91597 l -16.49236,-0.11945 V -0.68605142 10.798429 h -0.8256 c -1.53109,0 -5.09758,2.09614 -6.79456,3.99338 -1.65639,1.85186 -4.54446,7.43871 -5.41264,10.47051 -0.25002,0.87312 -0.58222,1.98437 -0.73823,2.46944 -0.39136,1.2169 -2.0765,7.30176 -3.12634,11.28889 -0.2052,0.7793 -0.33685,-11.27627 -0.35693,-32.6846104 l -0.0318,-33.9193396 1.55319,-0.12371 c 0.85426,-0.068 12.32395,-0.10028 25.4882,-0.0716 20.69377,0.045 24.2694,0.12953 26.40444,0.62402 3.9887,0.92382 7.58472,2.04932 7.58472,2.3739 0,0.16576 0.52886,0.30139 1.17524,0.30139 2.09331,0 10.76432,4.87704 10.22435,5.75072 -0.12186,0.19718 -0.0447,0.24734 0.17328,0.11263 0.60692,-0.3751 4.21691,3.0333 6.9953,6.60467 2.06429,2.6534496 4.63504,8.4775396 5.94174,13.4611396 1.7681,6.7433 1.74625,15.8657704 -0.0549,22.9305504 -2.11084,8.27937 -4.97852,13.41407 -10.75456,19.25647 -2.59968,2.62955 -8.78375,7.02548 -9.88326,7.02548 -0.27557,0 -0.68644,0.1854 -0.91304,0.412 -0.39593,0.39593 -0.78905,0.56749 -4.31522,1.88319 -3.68968,1.37672 -10.83412,2.28545 -13.21446,1.68081 z m 7.57002,-15.26489 c 0,-0.19403 -0.07,-0.35278 -0.15557,-0.35278 -0.0856,0 -0.25368,0.15875 -0.3736,0.35278 -0.11992,0.19403 -0.0499,0.35278 0.15557,0.35278 0.20548,0 0.3736,-0.15875 0.3736,-0.35278 z"
|
||||
id="path1" /></g></svg>
|
||||
|
Before Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 361 KiB |
|
After Width: | Height: | Size: 558 KiB |
|
Before Width: | Height: | Size: 2.4 MiB |
|
Before Width: | Height: | Size: 937 KiB |
|
Before Width: | Height: | Size: 566 KiB |
|
Before Width: | Height: | Size: 365 KiB |
@@ -1,179 +0,0 @@
|
||||
/**
|
||||
* Synchronous startup splash theme (before the Vite bundle loads).
|
||||
* Keep palette ids/hex in sync with `src/config/startupSplashPalettes.ts`.
|
||||
*/
|
||||
(function startupSplashPreflight() {
|
||||
var THEME_KEY = 'psysonic_theme';
|
||||
var INSTALLED_KEY = 'psysonic_installed_themes';
|
||||
var DEFAULT = {
|
||||
bg: '#1e1e2e',
|
||||
text: '#cdd6f4',
|
||||
muted: '#a6adc8',
|
||||
accent: '#cba6f7',
|
||||
track: '#313244',
|
||||
logoStart: '#cba6f7',
|
||||
logoEnd: '#89b4fa',
|
||||
};
|
||||
var BUILTIN = {
|
||||
mocha: DEFAULT,
|
||||
latte: {
|
||||
bg: '#eff1f5',
|
||||
text: '#4c4f69',
|
||||
muted: '#6c6f85',
|
||||
accent: '#8839ef',
|
||||
track: '#ccd0da',
|
||||
logoStart: '#8839ef',
|
||||
logoEnd: '#1e66f5',
|
||||
},
|
||||
'kanagawa-wave': {
|
||||
bg: '#1F1F28',
|
||||
text: '#DCD7BA',
|
||||
muted: '#727169',
|
||||
accent: '#7E9CD8',
|
||||
track: '#2A2A37',
|
||||
logoStart: '#7E9CD8',
|
||||
logoEnd: '#957FB8',
|
||||
},
|
||||
'stark-hud': {
|
||||
bg: '#0b0f15',
|
||||
text: '#e0f7fa',
|
||||
muted: '#7da5aa',
|
||||
accent: '#00f2ff',
|
||||
track: '#141b24',
|
||||
logoStart: '#00f2ff',
|
||||
logoEnd: '#7df9ff',
|
||||
},
|
||||
'vision-dark': {
|
||||
bg: '#0d0b12',
|
||||
text: '#f2eef8',
|
||||
muted: '#a6a2b8',
|
||||
accent: '#ffd700',
|
||||
track: '#16131e',
|
||||
logoStart: '#ffd700',
|
||||
logoEnd: '#a07af8',
|
||||
},
|
||||
'vision-navy': {
|
||||
bg: '#0a1628',
|
||||
text: '#e8eef8',
|
||||
muted: '#9caac2',
|
||||
accent: '#ffd700',
|
||||
track: '#12213a',
|
||||
logoStart: '#ffd700',
|
||||
logoEnd: '#a07af8',
|
||||
},
|
||||
};
|
||||
|
||||
function readCssVar(css, name) {
|
||||
var match = css.match(new RegExp(name + '\\s*:\\s*([^;]+);'));
|
||||
var value = match && match[1] ? match[1].trim() : '';
|
||||
return value || null;
|
||||
}
|
||||
|
||||
function resolveScheduledTheme(state) {
|
||||
if (!state.enableThemeScheduler) return state.theme;
|
||||
var now = new Date();
|
||||
var nowMins = now.getHours() * 60 + now.getMinutes();
|
||||
var dayParts = state.timeDayStart.split(':').map(Number);
|
||||
var nightParts = state.timeNightStart.split(':').map(Number);
|
||||
var dayMins = dayParts[0] * 60 + dayParts[1];
|
||||
var nightMins = nightParts[0] * 60 + nightParts[1];
|
||||
var isDay = dayMins < nightMins
|
||||
? nowMins >= dayMins && nowMins < nightMins
|
||||
: nowMins >= dayMins || nowMins < nightMins;
|
||||
return isDay ? state.themeDay : state.themeNight;
|
||||
}
|
||||
|
||||
function readThemeState() {
|
||||
try {
|
||||
var raw = localStorage.getItem(THEME_KEY);
|
||||
if (!raw) return null;
|
||||
var parsed = JSON.parse(raw);
|
||||
var s = parsed && parsed.state;
|
||||
if (!s) return null;
|
||||
return {
|
||||
enableThemeScheduler: !!s.enableThemeScheduler,
|
||||
theme: String(s.theme || 'mocha'),
|
||||
themeDay: String(s.themeDay || 'latte'),
|
||||
themeNight: String(s.themeNight || 'mocha'),
|
||||
timeDayStart: String(s.timeDayStart || '07:00'),
|
||||
timeNightStart: String(s.timeNightStart || '19:00'),
|
||||
};
|
||||
} catch (_err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readInstalledThemes() {
|
||||
try {
|
||||
var raw = localStorage.getItem(INSTALLED_KEY);
|
||||
if (!raw) return [];
|
||||
var parsed = JSON.parse(raw);
|
||||
var themes = parsed && parsed.state && parsed.state.themes;
|
||||
return Array.isArray(themes) ? themes : [];
|
||||
} catch (_err) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function paletteForTheme(themeId, installedThemes) {
|
||||
if (BUILTIN[themeId]) return BUILTIN[themeId];
|
||||
for (var i = 0; i < installedThemes.length; i += 1) {
|
||||
var theme = installedThemes[i];
|
||||
if (!theme || theme.id !== themeId || !theme.css) continue;
|
||||
var bg = readCssVar(theme.css, '--bg-app');
|
||||
var accent = readCssVar(theme.css, '--accent');
|
||||
if (!bg || !accent) break;
|
||||
var logoStart = readCssVar(theme.css, '--logo-color-start') || accent;
|
||||
var logoEnd = readCssVar(theme.css, '--logo-color-end')
|
||||
|| readCssVar(theme.css, '--accent-2')
|
||||
|| accent;
|
||||
return {
|
||||
bg: bg,
|
||||
text: readCssVar(theme.css, '--text-primary') || readCssVar(theme.css, '--ctp-text') || DEFAULT.text,
|
||||
muted: readCssVar(theme.css, '--text-muted') || readCssVar(theme.css, '--ctp-subtext0') || DEFAULT.muted,
|
||||
accent: accent,
|
||||
track: readCssVar(theme.css, '--bg-card') || readCssVar(theme.css, '--border-subtle') || DEFAULT.track,
|
||||
logoStart: logoStart,
|
||||
logoEnd: logoEnd,
|
||||
};
|
||||
}
|
||||
return DEFAULT;
|
||||
}
|
||||
|
||||
function applyPalette(themeId, palette) {
|
||||
var root = document.documentElement;
|
||||
root.setAttribute('data-theme', themeId);
|
||||
root.style.setProperty('--startup-splash-bg', palette.bg);
|
||||
root.style.setProperty('--startup-splash-text', palette.text);
|
||||
root.style.setProperty('--startup-splash-muted', palette.muted);
|
||||
root.style.setProperty('--startup-splash-accent', palette.accent);
|
||||
root.style.setProperty('--startup-splash-track', palette.track);
|
||||
root.style.setProperty('--startup-splash-logo-start', palette.logoStart);
|
||||
root.style.setProperty('--startup-splash-logo-end', palette.logoEnd);
|
||||
root.style.background = palette.bg;
|
||||
if (document.body) document.body.style.background = palette.bg;
|
||||
}
|
||||
|
||||
var persisted = readThemeState();
|
||||
function readStartMinimizedToTray() {
|
||||
try {
|
||||
var raw = localStorage.getItem('psysonic-auth');
|
||||
if (!raw) return false;
|
||||
var parsed = JSON.parse(raw);
|
||||
var state = parsed && parsed.state;
|
||||
if (!state || !state.startMinimizedToTray) return false;
|
||||
return state.showTrayIcon !== false;
|
||||
} catch (_err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
var themeId = persisted ? resolveScheduledTheme(persisted) : 'mocha';
|
||||
var palette = paletteForTheme(themeId, readInstalledThemes());
|
||||
applyPalette(themeId, palette);
|
||||
var trayHandled = false;
|
||||
try {
|
||||
trayHandled = sessionStorage.getItem('psy-startup-tray-handled') === '1';
|
||||
} catch (_err) {}
|
||||
window.__psyStartMinimizedToTray = readStartMinimizedToTray() && !trayHandled;
|
||||
})();
|
||||
@@ -1,57 +0,0 @@
|
||||
/**
|
||||
* Show the native window after the inline startup splash has painted.
|
||||
* When starting minimized to tray, hide the main window as early as possible
|
||||
* (visible:false may still map briefly on some Linux WMs before this script).
|
||||
* __TAURI_INTERNALS__ may not exist yet when this script first runs.
|
||||
*/
|
||||
(function startupSplashReveal() {
|
||||
var MAX_ATTEMPTS = 60;
|
||||
|
||||
function tryShowMainWindow() {
|
||||
var internals = window.__TAURI_INTERNALS__;
|
||||
if (!internals || typeof internals.invoke !== 'function') return false;
|
||||
internals.invoke('plugin:window|show', { label: 'main' }).catch(function () {});
|
||||
return true;
|
||||
}
|
||||
|
||||
function tryHideMainWindow() {
|
||||
var internals = window.__TAURI_INTERNALS__;
|
||||
if (!internals || typeof internals.invoke !== 'function') return false;
|
||||
internals.invoke('plugin:window|hide', { label: 'main' }).catch(function () {});
|
||||
return true;
|
||||
}
|
||||
|
||||
function reveal(attempt) {
|
||||
if (window.__psyStartMinimizedToTray) {
|
||||
if (tryHideMainWindow()) return;
|
||||
if (attempt >= MAX_ATTEMPTS) return;
|
||||
window.setTimeout(function () {
|
||||
reveal(attempt + 1);
|
||||
}, 50);
|
||||
return;
|
||||
}
|
||||
if (tryShowMainWindow()) return;
|
||||
if (attempt >= MAX_ATTEMPTS) return;
|
||||
window.setTimeout(function () {
|
||||
reveal(attempt + 1);
|
||||
}, 50);
|
||||
}
|
||||
|
||||
if (window.__psyStartMinimizedToTray) {
|
||||
// Mark this synchronously, before React mounts. This deliberately does
|
||||
// not set the CSS animation-pause attribute: entrance animations may
|
||||
// still mount while the native window is hidden.
|
||||
window.__psyHidden = true;
|
||||
try {
|
||||
sessionStorage.setItem('psy-startup-tray-handled', '1');
|
||||
} catch (_err) {}
|
||||
reveal(0);
|
||||
return;
|
||||
}
|
||||
|
||||
requestAnimationFrame(function () {
|
||||
requestAnimationFrame(function () {
|
||||
reveal(0);
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -1,52 +0,0 @@
|
||||
# Scripts
|
||||
|
||||
## install.sh - Auto-Installer for Debian and RHEL-based Systems
|
||||
|
||||
This script automatically downloads and installs the latest Psysonic release from GitHub Releases.
|
||||
|
||||
### Supported Distributions
|
||||
|
||||
- **Debian/Ubuntu**: Downloads and installs `.deb` package
|
||||
- **RHEL/Fedora/CentOS**: Downloads and installs `.rpm` package
|
||||
|
||||
### Usage
|
||||
|
||||
#### Quick Install (Recommended)
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Psychotoxical/psysonic/main/scripts/install.sh | sudo bash
|
||||
```
|
||||
|
||||
#### Manual Installation
|
||||
|
||||
```bash
|
||||
# Download the script
|
||||
wget https://raw.githubusercontent.com/Psychotoxical/psysonic/main/scripts/install.sh
|
||||
|
||||
# Make it executable
|
||||
chmod +x install.sh
|
||||
|
||||
# Run with sudo
|
||||
sudo ./install.sh
|
||||
```
|
||||
|
||||
### What it does
|
||||
|
||||
1. Detects your OS type (Debian or RHEL-based)
|
||||
2. Fetches the latest release from GitHub
|
||||
3. Downloads the appropriate package (.deb or .rpm)
|
||||
4. Installs it using your system's package manager
|
||||
5. Cleans up temporary files
|
||||
|
||||
### Requirements
|
||||
|
||||
- `curl` - for downloading packages
|
||||
- `sudo` or root access
|
||||
- Internet connection
|
||||
- Supported package manager (apt-get, dnf, or yum)
|
||||
|
||||
### Notes
|
||||
|
||||
- If Psysonic is already installed, the script will ask if you want to reinstall
|
||||
- The script automatically handles dependency installation for Debian systems
|
||||
- After installation, you can launch Psysonic from your application menu or by running `psysonic` in the terminal
|
||||
@@ -1,72 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Post-build guard: boot-adjacent Vite chunks must not bundle lucide-react.
|
||||
*
|
||||
* When a store/utils barrel accidentally re-exports UI, Rollup may pull
|
||||
* createLucideIcon into authStore/offline chunks and hit TDZ init-order bugs
|
||||
* in production (Windows WebView2: "X is not a function" on splash).
|
||||
*
|
||||
* Run after `npm run build` — no Tauri compile needed.
|
||||
*/
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const DIST = join(new URL('..', import.meta.url).pathname, 'dist/assets');
|
||||
|
||||
/** Chunk filename prefixes that must stay lucide-free. */
|
||||
const BOOT_CHUNK_PREFIXES = ['authStore-', 'offline-'];
|
||||
|
||||
/** Minified bundles still embed lucide module paths or icon factory calls. */
|
||||
const LUCIDE_SIGNALS = [
|
||||
'lucide-react',
|
||||
'createLucideIcon',
|
||||
// Common preset/offline icons pulled through bad barrels (minified arg strings):
|
||||
'("globe"',
|
||||
'("settings"',
|
||||
'("wifi-off"',
|
||||
'("download"',
|
||||
];
|
||||
|
||||
/** Subsonic client id must be a compile-time literal, not a cyclic package.json import. */
|
||||
const CLIENT_ID_SIGNALS = [
|
||||
'psysonic/undefined',
|
||||
'psysonic/${',
|
||||
];
|
||||
|
||||
let files;
|
||||
try {
|
||||
files = readdirSync(DIST).filter(f => f.endsWith('.js'));
|
||||
} catch {
|
||||
console.error('check-boot-chunk-lucide: dist/assets not found — run `npm run build` first.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const violations = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (!BOOT_CHUNK_PREFIXES.some(p => file.startsWith(p))) continue;
|
||||
const text = readFileSync(join(DIST, file), 'utf8');
|
||||
for (const signal of LUCIDE_SIGNALS) {
|
||||
if (text.includes(signal)) {
|
||||
violations.push({ file, signal });
|
||||
}
|
||||
}
|
||||
for (const signal of CLIENT_ID_SIGNALS) {
|
||||
if (text.includes(signal)) {
|
||||
violations.push({ file, signal: `client-id: ${signal}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (violations.length > 0) {
|
||||
console.error('Lucide leaked into boot-critical chunks:\n');
|
||||
for (const { file, signal } of violations) {
|
||||
console.error(` • dist/assets/${file} — matched "${signal}"`);
|
||||
}
|
||||
console.error(
|
||||
'\nFix: split UI from the feature root barrel; import UI from @/features/<x>/ui only in components.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('check-boot-chunk-lucide: ok');
|
||||
@@ -1,63 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Walk the same root CSS entrypoints as src/main.tsx and ensure every
|
||||
* relative @import target exists on disk. Catches postcss-import ENOENT
|
||||
* that Vitest does not surface (CSS is not fully resolved like vite build).
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
const stylesRoot = path.join(repoRoot, 'src', 'styles');
|
||||
|
||||
const ENTRY_RELS = [
|
||||
'themes/index.css',
|
||||
'layout/index.css',
|
||||
'components/index.css',
|
||||
'tracks/index.css',
|
||||
];
|
||||
|
||||
/** Quoted path in @import '...' or "..." (postcss-import style in this repo). */
|
||||
const IMPORT_RE = /@import\s+(['"])([^'"]+)\1/g;
|
||||
|
||||
function isFilesystemImport(spec) {
|
||||
if (spec.startsWith('http://') || spec.startsWith('https://') || spec.startsWith('data:')) {
|
||||
return false;
|
||||
}
|
||||
if (spec.startsWith('@')) return false;
|
||||
return spec.startsWith('./') || spec.startsWith('../');
|
||||
}
|
||||
|
||||
function walk(absPath, visited, missing) {
|
||||
const key = path.resolve(absPath);
|
||||
if (visited.has(key)) return;
|
||||
if (!fs.existsSync(key)) {
|
||||
missing.add(path.relative(repoRoot, key));
|
||||
visited.add(key);
|
||||
return;
|
||||
}
|
||||
visited.add(key);
|
||||
const text = fs.readFileSync(key, 'utf8');
|
||||
let m;
|
||||
IMPORT_RE.lastIndex = 0;
|
||||
while ((m = IMPORT_RE.exec(text)) !== null) {
|
||||
const spec = m[2];
|
||||
if (!isFilesystemImport(spec)) continue;
|
||||
const next = path.resolve(path.dirname(key), spec);
|
||||
walk(next, visited, missing);
|
||||
}
|
||||
}
|
||||
|
||||
const visited = new Set();
|
||||
const missing = new Set();
|
||||
for (const rel of ENTRY_RELS) {
|
||||
walk(path.join(stylesRoot, rel), visited, missing);
|
||||
}
|
||||
|
||||
if (missing.size > 0) {
|
||||
const list = [...missing].sort().join('\n ');
|
||||
console.error(`check-css-import-graph: missing @import target(s):\n ${list}`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Guard: boot-critical feature barrels must not re-export UI modules.
|
||||
*
|
||||
* Re-exporting components/hooks that pull lucide-react through a barrel while
|
||||
* the same barrel (or its stores) is imported from boot paths creates production
|
||||
* init-order failures (`createLucideIcon is not a function` in minified chunks).
|
||||
*
|
||||
* Not every feature barrel is checked — album/artist/etc. export UI for lazy
|
||||
* routes and are safe. Only barrels that stores/utils on the boot path import
|
||||
* from are listed in BOOT_CRITICAL_BARRELS.
|
||||
*/
|
||||
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const ROOT = new URL('..', import.meta.url).pathname;
|
||||
|
||||
/** Barrels whose non-UI surface is imported before or during first paint. */
|
||||
const BOOT_CRITICAL_BARRELS = [
|
||||
{ file: join(ROOT, 'src/features/offline/index.ts'), label: 'src/features/offline/index.ts' },
|
||||
{ file: join(ROOT, 'src/music-network/index.ts'), label: 'src/music-network/index.ts' },
|
||||
];
|
||||
|
||||
const FORBIDDEN_EXPORT_PATTERNS = [
|
||||
/from\s+['"]\.\/components\//,
|
||||
/from\s+['"]\.\/ui\//,
|
||||
/export\s+\*\s+from\s+['"]\.\/components\//,
|
||||
/export\s+\*\s+from\s+['"]\.\/ui\//,
|
||||
/export\s+\{[^}]*\}\s+from\s+['"]\.\/components\//,
|
||||
/export\s+\{[^}]*\}\s+from\s+['"]\.\/ui\//,
|
||||
];
|
||||
|
||||
/** @param {string} file @param {string} label */
|
||||
function checkBarrel(file, label) {
|
||||
const text = readFileSync(file, 'utf8');
|
||||
const hits = [];
|
||||
for (const re of FORBIDDEN_EXPORT_PATTERNS) {
|
||||
for (const line of text.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith('//') || trimmed.startsWith('*')) continue;
|
||||
if (re.test(line)) hits.push(trimmed);
|
||||
}
|
||||
}
|
||||
if (hits.length === 0) return [];
|
||||
return hits.map(h => `${label}: ${h}`);
|
||||
}
|
||||
|
||||
const errors = [];
|
||||
|
||||
for (const { file, label } of BOOT_CRITICAL_BARRELS) {
|
||||
try {
|
||||
statSync(file);
|
||||
errors.push(...checkBarrel(file, label));
|
||||
} catch {
|
||||
errors.push(`${label}: missing barrel file`);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error('Boot-critical barrel UI re-export violations:\n');
|
||||
for (const e of errors) console.error(` • ${e}`);
|
||||
console.error(
|
||||
'\nFix: remove UI exports from the root barrel; import from @/features/<x>/ui or deep paths.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('check-feature-barrel-ui: ok');
|
||||
@@ -1,105 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Hot-path file coverage gate — frontend.
|
||||
#
|
||||
# Mirrors `scripts/check-hot-path-coverage.sh` for the Rust workspace. For
|
||||
# each source file listed in `.github/frontend-hot-path-files.txt`, verifies
|
||||
# that line coverage is at least $THRESHOLD %. Emits GitHub Actions warning
|
||||
# annotations for files below the floor and exits 1 when any file is below.
|
||||
#
|
||||
# Why files instead of per-function: v8 coverage's per-function data is
|
||||
# fragile under React Compiler / Vite minification — file-level line
|
||||
# coverage tracks the underlying intent ("is the hot-path file thoroughly
|
||||
# tested?") more robustly.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/check-frontend-hot-path-coverage.sh [<summary.json>] [<hot-path-list.txt>]
|
||||
#
|
||||
# Defaults:
|
||||
# summary.json — coverage/coverage-summary.json
|
||||
# hot-path-list.txt — .github/frontend-hot-path-files.txt
|
||||
#
|
||||
# Requires: jq.
|
||||
|
||||
set -euo pipefail
|
||||
export LC_ALL=C
|
||||
|
||||
JSON="${1:-coverage/coverage-summary.json}"
|
||||
HOT_PATH_LIST="${2:-.github/frontend-hot-path-files.txt}"
|
||||
THRESHOLD=70
|
||||
|
||||
if [[ ! -f "$JSON" ]]; then
|
||||
echo "::error::Coverage summary not found at $JSON. Did you run 'npm run test:coverage' first?"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ ! -f "$HOT_PATH_LIST" ]]; then
|
||||
echo "::error::Hot-path file list not found at $HOT_PATH_LIST"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if ! command -v jq >/dev/null 2>&1; then
|
||||
echo "::error::jq not found in PATH. Install via apt-get install jq / brew install jq / winget install jqlang.jq"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# The v8 / Istanbul coverage-summary.json has the form:
|
||||
# { "total": {...}, "<absolute-path>": { "lines": { "pct": 87.5 }, ... }, ... }
|
||||
# We want each file path + its line pct as a TSV keyed by basename suffix.
|
||||
ALL_FILES=$(mktemp)
|
||||
trap 'rm -f "$ALL_FILES"' EXIT
|
||||
jq -r 'to_entries[] | select(.key != "total") | [.key, .value.lines.pct] | @tsv' "$JSON" > "$ALL_FILES"
|
||||
|
||||
TOTAL=0
|
||||
BELOW=0
|
||||
NOT_FOUND=0
|
||||
|
||||
echo "── Hot-path file coverage check, frontend (threshold: ≥${THRESHOLD}%) ──"
|
||||
|
||||
while IFS= read -r raw_line || [[ -n "$raw_line" ]]; do
|
||||
line="${raw_line%%#*}"
|
||||
line="${line#"${line%%[![:space:]]*}"}"
|
||||
line="${line%"${line##*[![:space:]]}"}"
|
||||
[[ -z "$line" ]] && continue
|
||||
TOTAL=$((TOTAL + 1))
|
||||
|
||||
# Match suffix — JSON paths are absolute, hot-path list is workspace-relative.
|
||||
pct=$(awk -F'\t' -v target="$line" '
|
||||
{
|
||||
path = $1
|
||||
gsub(/\\\\/, "/", path)
|
||||
gsub(/\\/, "/", path)
|
||||
n = length(path)
|
||||
tlen = length(target)
|
||||
if (n >= tlen && substr(path, n - tlen + 1) == target) {
|
||||
printf "%s\n", $2
|
||||
exit
|
||||
}
|
||||
}
|
||||
' "$ALL_FILES")
|
||||
|
||||
if [[ -z "$pct" ]]; then
|
||||
echo "::warning::Hot-path file '$line' not found in coverage report (deleted? renamed? or no test imports it yet)"
|
||||
NOT_FOUND=$((NOT_FOUND + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
pct_int=${pct%.*}
|
||||
if [[ "$pct_int" -lt "$THRESHOLD" ]]; then
|
||||
printf "::warning::Hot-path file '%s': %.1f%% — below %d%%\n" "$line" "$pct" "$THRESHOLD"
|
||||
BELOW=$((BELOW + 1))
|
||||
else
|
||||
printf " ok %s %.1f%%\n" "$line" "$pct"
|
||||
fi
|
||||
done < "$HOT_PATH_LIST"
|
||||
|
||||
echo
|
||||
echo "── Summary ─────────────────────────────────────────────────────────"
|
||||
echo "Checked: $TOTAL hot-path file(s)"
|
||||
echo "Below threshold: $BELOW"
|
||||
echo "Not found: $NOT_FOUND"
|
||||
|
||||
if [[ "$BELOW" -gt 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
@@ -1,110 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Hot-path file coverage gate.
|
||||
#
|
||||
# For each source file listed in `.github/hot-path-files.txt`, verifies
|
||||
# that line coverage is at least $THRESHOLD %. Emits GitHub Actions
|
||||
# warning annotations for files below the floor and exits 1 when any
|
||||
# file is below.
|
||||
#
|
||||
# Why files instead of per-function: cargo-llvm-cov's per-function
|
||||
# region data is unreliable for async state-machines (most regions live
|
||||
# in synthetic closures) and generic functions (every instantiation is
|
||||
# a separate symbol). File-level line coverage is robustly measured and
|
||||
# tracks the underlying intent: "is the hot-path file thoroughly tested?".
|
||||
#
|
||||
# Usage:
|
||||
# scripts/check-hot-path-coverage.sh [<coverage.json>] [<hot-path-list.txt>]
|
||||
#
|
||||
# Defaults:
|
||||
# coverage.json — src-tauri/target/llvm-cov/cov.json
|
||||
# hot-path-list.txt — .github/hot-path-files.txt
|
||||
#
|
||||
# Requires: jq (preinstalled on Ubuntu runners; on Windows install via
|
||||
# `winget install jqlang.jq` or `choco install jq`).
|
||||
|
||||
set -euo pipefail
|
||||
export LC_ALL=C
|
||||
|
||||
JSON="${1:-src-tauri/target/llvm-cov/cov.json}"
|
||||
HOT_PATH_LIST="${2:-.github/hot-path-files.txt}"
|
||||
THRESHOLD=70
|
||||
|
||||
if [[ ! -f "$JSON" ]]; then
|
||||
echo "::error::Coverage JSON not found at $JSON. Did you run cargo llvm-cov --workspace --json --output-path \"$JSON\" first?"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ ! -f "$HOT_PATH_LIST" ]]; then
|
||||
echo "::error::Hot-path file list not found at $HOT_PATH_LIST"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if ! command -v jq >/dev/null 2>&1; then
|
||||
echo "::error::jq not found in PATH. Install via apt-get install jq / brew install jq / winget install jqlang.jq"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Pre-extract every file's line coverage % into a TSV keyed by basename.
|
||||
# We match by suffix (file path ends with the listed relative path) because
|
||||
# the JSON stores absolute paths that vary between Windows runners and Linux.
|
||||
ALL_FILES=$(mktemp)
|
||||
trap 'rm -f "$ALL_FILES"' EXIT
|
||||
jq -r '.data[0].files[] | [.filename, .summary.lines.percent] | @tsv' "$JSON" > "$ALL_FILES"
|
||||
|
||||
TOTAL=0
|
||||
BELOW=0
|
||||
NOT_FOUND=0
|
||||
|
||||
echo "── Hot-path file coverage check (threshold: ≥${THRESHOLD}%) ──────────"
|
||||
|
||||
while IFS= read -r raw_line || [[ -n "$raw_line" ]]; do
|
||||
line="${raw_line%%#*}" # strip trailing comment
|
||||
line="${line#"${line%%[![:space:]]*}"}" # ltrim
|
||||
line="${line%"${line##*[![:space:]]}"}" # rtrim
|
||||
[[ -z "$line" ]] && continue
|
||||
TOTAL=$((TOTAL + 1))
|
||||
|
||||
# Match suffix — the JSON stores absolute paths; the hot-path list uses
|
||||
# workspace-relative paths. Convert both to forward slashes first so the
|
||||
# endsWith works on Windows-encoded paths too.
|
||||
pct=$(awk -F'\t' -v target="$line" '
|
||||
{
|
||||
path = $1
|
||||
gsub(/\\\\/, "/", path)
|
||||
gsub(/\\/, "/", path)
|
||||
n = length(path)
|
||||
tlen = length(target)
|
||||
if (n >= tlen && substr(path, n - tlen + 1) == target) {
|
||||
printf "%s\n", $2
|
||||
exit
|
||||
}
|
||||
}
|
||||
' "$ALL_FILES")
|
||||
|
||||
if [[ -z "$pct" ]]; then
|
||||
echo "::warning::Hot-path file '$line' not found in coverage report (deleted? renamed?)"
|
||||
NOT_FOUND=$((NOT_FOUND + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
# bash arithmetic doesn't do float. Truncate to int for comparison.
|
||||
pct_int=${pct%.*}
|
||||
if [[ "$pct_int" -lt "$THRESHOLD" ]]; then
|
||||
printf "::warning::Hot-path file '%s': %.1f%% — below %d%%\n" "$line" "$pct" "$THRESHOLD"
|
||||
BELOW=$((BELOW + 1))
|
||||
else
|
||||
printf " ok %s %.1f%%\n" "$line" "$pct"
|
||||
fi
|
||||
done < "$HOT_PATH_LIST"
|
||||
|
||||
echo
|
||||
echo "── Summary ─────────────────────────────────────────────────────────"
|
||||
echo "Checked: $TOTAL hot-path file(s)"
|
||||
echo "Below threshold: $BELOW"
|
||||
echo "Not found: $NOT_FOUND"
|
||||
|
||||
if [[ "$BELOW" -gt 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
@@ -1,134 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Compare two environment dumps from dump-environment.sh
|
||||
# Usage: ./scripts/compare-environment.sh FILE_A FILE_B
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -ne 2 ]]; then
|
||||
echo "Usage: $0 FILE_A FILE_B" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
FILE_A="$1"
|
||||
FILE_B="$2"
|
||||
|
||||
for f in "$FILE_A" "$FILE_B"; do
|
||||
if [[ ! -f "$f" ]]; then
|
||||
echo "Missing file: $f" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
parse_dump() {
|
||||
local file="$1"
|
||||
awk -F= '
|
||||
/^\[/ {
|
||||
section = substr($0, 2, length($0) - 2)
|
||||
next
|
||||
}
|
||||
/^[[:space:]]*$/ { next }
|
||||
/^#/ { next }
|
||||
{
|
||||
key = $1
|
||||
$1 = ""
|
||||
sub(/^=/, "", $0)
|
||||
full = (section == "" ? key : section "." key)
|
||||
print full "\t" $0
|
||||
}
|
||||
' "$file" | sort -u
|
||||
}
|
||||
|
||||
TMP_A="$(mktemp)"
|
||||
TMP_B="$(mktemp)"
|
||||
TMP_JOIN="$(mktemp)"
|
||||
trap 'rm -f "$TMP_A" "$TMP_B" "$TMP_JOIN"' EXIT
|
||||
|
||||
parse_dump "$FILE_A" >"$TMP_A"
|
||||
parse_dump "$FILE_B" >"$TMP_B"
|
||||
|
||||
join -t $'\t' -a 1 -a 2 -e '—' -o '0,1.2,2.2' "$TMP_A" "$TMP_B" >"$TMP_JOIN"
|
||||
|
||||
ONLY_A=0
|
||||
ONLY_B=0
|
||||
DIFF=0
|
||||
SAME=0
|
||||
|
||||
echo "Comparing:"
|
||||
echo " A: $FILE_A"
|
||||
echo " B: $FILE_B"
|
||||
echo
|
||||
|
||||
while IFS=$'\t' read -r key val_a val_b; do
|
||||
[[ -z "$key" ]] && continue
|
||||
if [[ "$val_a" == "—" ]]; then
|
||||
ONLY_B=$((ONLY_B + 1))
|
||||
printf ' + %-40s B=%s\n' "$key" "$val_b"
|
||||
elif [[ "$val_b" == "—" ]]; then
|
||||
ONLY_A=$((ONLY_A + 1))
|
||||
printf ' - %-40s A=%s\n' "$key" "$val_a"
|
||||
elif [[ "$val_a" != "$val_b" ]]; then
|
||||
DIFF=$((DIFF + 1))
|
||||
printf ' ≠ %-40s\n A=%s\n B=%s\n' "$key" "$val_a" "$val_b"
|
||||
else
|
||||
SAME=$((SAME + 1))
|
||||
fi
|
||||
done <"$TMP_JOIN"
|
||||
|
||||
echo
|
||||
echo "Summary: same=$SAME different=$DIFF only_in_A=$ONLY_A only_in_B=$ONLY_B"
|
||||
|
||||
# High-signal keys for the common "works on one NixOS box" case.
|
||||
echo
|
||||
echo "High-signal differences (if any):"
|
||||
HIGH_SIGNAL=0
|
||||
while IFS=$'\t' read -r key val_a val_b; do
|
||||
[[ "$val_a" == "$val_b" ]] && continue
|
||||
case "$key" in
|
||||
meta.flake_lock_sha256|meta.nixpkgs_locked_rev|meta.npm_lock_sha256|meta.git_rev|\
|
||||
installed_app.psysonic_realpath|installed_app.psysonic_closure_paths|installed_app.psysonic_drv|\
|
||||
runtime_env.HTTP_PROXY|runtime_env.HTTPS_PROXY|runtime_env.http_proxy|runtime_env.https_proxy|\
|
||||
runtime_env.NO_PROXY|runtime_env.no_proxy|runtime_env.SSL_CERT_FILE|runtime_env.NIX_SSL_CERT_FILE|\
|
||||
toolchain_nix_develop.node_realpath|toolchain_nix_develop.rustc_realpath|\
|
||||
host.nixos_version|host.uname|\
|
||||
app_config_paths.data_dir|app_config_paths.localstorage_db_path|\
|
||||
app_preferences.language|app_servers.active_server_id|app_servers.server_count|\
|
||||
app_servers.server.*.url|app_servers.server.*.alternateUrl|\
|
||||
app_servers.server.*.customHeaders_count|app_servers.server.*.customHeadersApplyTo|\
|
||||
app_servers.server.*.password_sha256|app_servers.server.*.customHeaders.*.name|\
|
||||
app_servers.server.*.customHeaders.*.value_sha256|\
|
||||
app_network_probe.probe.*)
|
||||
HIGH_SIGNAL=$((HIGH_SIGNAL + 1))
|
||||
printf ' ! %s\n A=%s\n B=%s\n' "$key" "$val_a" "$val_b"
|
||||
;;
|
||||
esac
|
||||
done <"$TMP_JOIN"
|
||||
|
||||
if [[ "$HIGH_SIGNAL" -eq 0 && "$DIFF" -eq 0 && "$ONLY_A" -eq 0 && "$ONLY_B" -eq 0 ]]; then
|
||||
echo " (none — environments match on recorded keys)"
|
||||
elif [[ "$HIGH_SIGNAL" -eq 0 ]]; then
|
||||
echo " (no high-signal keys differ; see full list above — may be npm patch-level deps only)"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Server / network config differences:"
|
||||
SERVER_DIFF=0
|
||||
while IFS=$'\t' read -r key val_a val_b; do
|
||||
[[ "$val_a" == "$val_b" ]] && continue
|
||||
case "$key" in
|
||||
app_servers.*|app_network_probe.*|app_config_paths.*|app_preferences.*)
|
||||
SERVER_DIFF=$((SERVER_DIFF + 1))
|
||||
printf ' • %s\n A=%s\n B=%s\n' "$key" "$val_a" "$val_b"
|
||||
;;
|
||||
esac
|
||||
done <"$TMP_JOIN"
|
||||
if [[ "$SERVER_DIFF" -eq 0 ]]; then
|
||||
echo " (none — server URLs, headers, and curl probes match)"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Reminder: server offline is usually URL/network/headers, not Node patch versions."
|
||||
echo "Next: curl the Navidrome URL from both hosts; compare Settings → Servers side by side."
|
||||
|
||||
if [[ "$DIFF" -gt 0 || "$ONLY_A" -gt 0 || "$ONLY_B" -gt 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,254 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Dump Psysonic-related toolchain, Nix closure hints, app config, and env for cross-machine diff.
|
||||
# Re-enters `nix develop` automatically when flake.nix is present (no manual dev shell needed).
|
||||
# Usage: ./scripts/dump-environment.sh [-o FILE]
|
||||
# Compare: ./scripts/compare-environment.sh a.txt b.txt
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
SCRIPT="$REPO_ROOT/scripts/dump-environment.sh"
|
||||
|
||||
# Bootstrap: run the rest inside the flake dev shell so node/jq match the project.
|
||||
if [[ -z "${PSYSONIC_ENV_DUMP_IN_NIX:-}" ]] && [[ -f "$REPO_ROOT/flake.nix" ]]; then
|
||||
if ! command -v nix >/dev/null 2>&1; then
|
||||
echo "$0: flake.nix found but nix is not on PATH — install Nix or run from a NixOS profile with flakes." >&2
|
||||
exit 1
|
||||
fi
|
||||
export PSYSONIC_ENV_DUMP_IN_NIX=1
|
||||
exec env REPO_ROOT="$REPO_ROOT" nix develop --command bash "$SCRIPT" "$@"
|
||||
fi
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
echo "$0: node not found after nix develop — check flake.nix devShell." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
OUTPUT_FILE=""
|
||||
while getopts 'o:h' opt; do
|
||||
case "$opt" in
|
||||
o) OUTPUT_FILE="$OPTARG" ;;
|
||||
h)
|
||||
echo "Usage: $0 [-o FILE]" >&2
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 [-o FILE]" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
emit() {
|
||||
if [[ -n "$OUTPUT_FILE" ]]; then
|
||||
printf '%s\n' "$*" >>"$OUTPUT_FILE"
|
||||
else
|
||||
printf '%s\n' "$*"
|
||||
fi
|
||||
}
|
||||
|
||||
kv() {
|
||||
local key="$1"
|
||||
local value="${2-}"
|
||||
value="${value//$'\n'/\\n}"
|
||||
emit "${key}=${value}"
|
||||
}
|
||||
|
||||
section() {
|
||||
emit ""
|
||||
emit "[$1]"
|
||||
}
|
||||
|
||||
run_optional() {
|
||||
"$@" 2>/dev/null || true
|
||||
}
|
||||
|
||||
if [[ -n "$OUTPUT_FILE" ]]; then
|
||||
: >"$OUTPUT_FILE"
|
||||
fi
|
||||
|
||||
section meta
|
||||
kv generated_at "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
kv in_nix_dev_shell "${PSYSONIC_ENV_DUMP_IN_NIX:-no}"
|
||||
kv hostname "$(hostname 2>/dev/null || echo unknown)"
|
||||
kv repo_root "$REPO_ROOT"
|
||||
if command -v git >/dev/null 2>&1 && git -C "$REPO_ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
kv git_rev "$(git -C "$REPO_ROOT" rev-parse HEAD 2>/dev/null || echo unknown)"
|
||||
kv git_branch "$(git -C "$REPO_ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null || echo unknown)"
|
||||
kv git_dirty "$(git -C "$REPO_ROOT" status --porcelain 2>/dev/null | wc -l | tr -d ' ')"
|
||||
else
|
||||
kv git_rev "n/a"
|
||||
kv git_branch "n/a"
|
||||
kv git_dirty "n/a"
|
||||
fi
|
||||
if [[ -f "$REPO_ROOT/package.json" ]]; then
|
||||
kv package_json_version "$(node -p "require('./package.json').version" 2>/dev/null || sed -n 's/.*\"version\": \"\\([^\"]*\\)\".*/\\1/p' "$REPO_ROOT/package.json" | head -1)"
|
||||
fi
|
||||
if [[ -f "$REPO_ROOT/flake.lock" ]]; then
|
||||
kv flake_lock_sha256 "$(sha256sum "$REPO_ROOT/flake.lock" | awk '{print $1}')"
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
kv nixpkgs_locked_rev "$(jq -r '.nodes.nixpkgs.locked.rev // "unknown"' "$REPO_ROOT/flake.lock" 2>/dev/null)"
|
||||
kv nixpkgs_locked_narHash "$(jq -r '.nodes.nixpkgs.locked.narHash // "unknown"' "$REPO_ROOT/flake.lock" 2>/dev/null)"
|
||||
fi
|
||||
fi
|
||||
if [[ -f "$REPO_ROOT/package-lock.json" ]]; then
|
||||
kv npm_lockfile_version "$(jq -r '.lockfileVersion // "unknown"' "$REPO_ROOT/package-lock.json" 2>/dev/null || echo unknown)"
|
||||
kv npm_lock_sha256 "$(sha256sum "$REPO_ROOT/package-lock.json" | awk '{print $1}')"
|
||||
fi
|
||||
|
||||
section host
|
||||
kv uname "$(uname -a 2>/dev/null || echo unknown)"
|
||||
if [[ -r /etc/os-release ]]; then
|
||||
# shellcheck disable=SC1091
|
||||
source /etc/os-release
|
||||
kv os_id "${ID:-unknown}"
|
||||
kv os_version "${VERSION_ID:-unknown}"
|
||||
kv os_pretty "${PRETTY_NAME:-unknown}"
|
||||
fi
|
||||
if command -v nixos-version >/dev/null 2>&1; then
|
||||
kv nixos_version "$(nixos-version 2>/dev/null || echo unknown)"
|
||||
fi
|
||||
|
||||
section nix
|
||||
if command -v nix >/dev/null 2>&1; then
|
||||
kv nix_version "$(nix --version 2>/dev/null | head -1)"
|
||||
kv nix_flake_present "$([[ -f "$REPO_ROOT/flake.nix" ]] && echo yes || echo no)"
|
||||
else
|
||||
kv nix_version "not_installed"
|
||||
kv nix_flake_present "$([[ -f "$REPO_ROOT/flake.nix" ]] && echo yes || echo no)"
|
||||
fi
|
||||
|
||||
dump_toolchain_block() {
|
||||
local label="$1"
|
||||
shift
|
||||
section "$label"
|
||||
for tool in node npm rustc cargo clippy jq cmake pkg-config; do
|
||||
if command -v "$tool" >/dev/null 2>&1; then
|
||||
case "$tool" in
|
||||
node) kv node_version "$("$tool" -v 2>/dev/null)" ;;
|
||||
npm) kv npm_version "$("$tool" -v 2>/dev/null)" ;;
|
||||
rustc) kv rustc_version "$("$tool" -V 2>/dev/null | head -1)" ;;
|
||||
cargo) kv cargo_version "$("$tool" -V 2>/dev/null | head -1)" ;;
|
||||
clippy) kv clippy_version "$("$tool" -V 2>/dev/null | head -1)" ;;
|
||||
jq) kv jq_version "$("$tool" --version 2>/dev/null | head -1)" ;;
|
||||
cmake) kv cmake_version "$("$tool" --version 2>/dev/null | head -1)" ;;
|
||||
pkg-config) kv pkg_config_version "$("$tool" --version 2>/dev/null | head -1)" ;;
|
||||
esac
|
||||
kv "${tool}_path" "$(command -v "$tool")"
|
||||
if [[ -L "$(command -v "$tool")" ]] || [[ -e "$(command -v "$tool")" ]]; then
|
||||
kv "${tool}_realpath" "$(readlink -f "$(command -v "$tool")" 2>/dev/null || echo unknown)"
|
||||
fi
|
||||
else
|
||||
kv "${tool}_version" "missing"
|
||||
fi
|
||||
done
|
||||
kv CARGO_TARGET_DIR "${CARGO_TARGET_DIR:-unset}"
|
||||
kv LD_LIBRARY_PATH "${LD_LIBRARY_PATH:-unset}"
|
||||
kv GST_PLUGIN_PATH "${GST_PLUGIN_PATH:-unset}"
|
||||
kv GIO_EXTRA_MODULES "${GIO_EXTRA_MODULES:-unset}"
|
||||
}
|
||||
|
||||
if [[ -n "${PSYSONIC_ENV_DUMP_IN_NIX:-}" ]]; then
|
||||
dump_toolchain_block toolchain_nix_develop
|
||||
elif command -v nix >/dev/null 2>&1 && [[ -f "$REPO_ROOT/flake.nix" ]]; then
|
||||
# No bootstrap (should not happen when flake exists) — capture devShell separately.
|
||||
NIX_DEV_DUMP="$(nix develop --command bash -lc '
|
||||
set +e
|
||||
cd "$REPO_ROOT" || exit 0
|
||||
for tool in node npm rustc cargo clippy jq; do
|
||||
if command -v "$tool" >/dev/null 2>&1; then
|
||||
case "$tool" in
|
||||
node) printf "node_version=%s\n" "$("$tool" -v)" ;;
|
||||
npm) printf "npm_version=%s\n" "$("$tool" -v)" ;;
|
||||
rustc) printf "rustc_version=%s\n" "$("$tool" -V | head -1)" ;;
|
||||
cargo) printf "cargo_version=%s\n" "$("$tool" -V | head -1)" ;;
|
||||
clippy) printf "clippy_version=%s\n" "$("$tool" -V | head -1)" ;;
|
||||
jq) printf "jq_version=%s\n" "$("$tool" --version | head -1)" ;;
|
||||
esac
|
||||
printf "%s_path=%s\n" "$tool" "$(command -v "$tool")"
|
||||
printf "%s_realpath=%s\n" "$tool" "$(readlink -f "$(command -v "$tool")" 2>/dev/null || echo unknown)"
|
||||
else
|
||||
printf "%s_version=missing\n" "$tool"
|
||||
fi
|
||||
done
|
||||
printf "CARGO_TARGET_DIR=%s\n" "${CARGO_TARGET_DIR:-unset}"
|
||||
printf "LD_LIBRARY_PATH=%s\n" "${LD_LIBRARY_PATH:-unset}"
|
||||
printf "GST_PLUGIN_PATH=%s\n" "${GST_PLUGIN_PATH:-unset}"
|
||||
printf "GIO_EXTRA_MODULES=%s\n" "${GIO_EXTRA_MODULES:-unset}"
|
||||
' REPO_ROOT="$REPO_ROOT" 2>/dev/null | tr -d '\r' || true)"
|
||||
if [[ -n "$NIX_DEV_DUMP" ]]; then
|
||||
section toolchain_nix_develop
|
||||
while IFS= read -r line; do
|
||||
[[ -n "$line" && "$line" == *=* ]] && emit "$line"
|
||||
done <<<"$NIX_DEV_DUMP"
|
||||
else
|
||||
section toolchain_nix_develop
|
||||
kv status "nix develop failed or unavailable"
|
||||
fi
|
||||
else
|
||||
dump_toolchain_block toolchain_ambient
|
||||
fi
|
||||
|
||||
section runtime_env
|
||||
for var in HTTP_PROXY HTTPS_PROXY ALL_PROXY NO_PROXY http_proxy https_proxy all_proxy no_proxy GDK_BACKEND PSYSONIC_SKIP_WAYLAND_FONT_TUNING PSYSONIC_ALLOW_NATIVE_GDK SSL_CERT_FILE SSL_CERT_DIR NIX_SSL_CERT_FILE; do
|
||||
kv "$var" "${!var-unset}"
|
||||
done
|
||||
kv navigator_online "n/a (browser-only)"
|
||||
|
||||
section installed_app
|
||||
if command -v psysonic >/dev/null 2>&1; then
|
||||
PSY_PATH="$(command -v psysonic)"
|
||||
kv psysonic_path "$PSY_PATH"
|
||||
kv psysonic_realpath "$(readlink -f "$PSY_PATH" 2>/dev/null || echo unknown)"
|
||||
run_optional kv psysonic_version "$(psysonic --version 2>/dev/null | head -1)"
|
||||
if command -v nix-store >/dev/null 2>&1; then
|
||||
kv psysonic_closure_paths "$(nix-store -qR "$PSY_PATH" 2>/dev/null | wc -l | tr -d ' ')"
|
||||
kv psysonic_drv "$(nix-store -q --deriver "$PSY_PATH" 2>/dev/null | sed 's/\.drv$//' | xargs -r basename 2>/dev/null || echo unknown)"
|
||||
fi
|
||||
else
|
||||
kv psysonic_path "not_in_path"
|
||||
fi
|
||||
|
||||
section npm_dependencies
|
||||
if [[ -f "$REPO_ROOT/package-lock.json" ]] && command -v node >/dev/null 2>&1; then
|
||||
REPO_ROOT="$REPO_ROOT" node <<'NODE' 2>/dev/null | while IFS= read -r line; do emit "$line"; done || true
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const lockPath = path.join(process.env.REPO_ROOT || '.', 'package-lock.json');
|
||||
let lock;
|
||||
try { lock = JSON.parse(fs.readFileSync(lockPath, 'utf8')); } catch { process.exit(0); }
|
||||
const root = lock.packages?.['']?.dependencies || {};
|
||||
const deps = Object.keys(root).sort();
|
||||
for (const name of deps) {
|
||||
const entry = lock.packages?.[`node_modules/${name}`] || lock.packages?.[name];
|
||||
const version = entry?.version || 'unknown';
|
||||
console.log(`dep.${name}=${version}`);
|
||||
}
|
||||
NODE
|
||||
else
|
||||
kv status "node or package-lock.json unavailable"
|
||||
fi
|
||||
|
||||
run_app_config_dump() {
|
||||
local extractor="$REPO_ROOT/scripts/lib/extract-app-config.mjs"
|
||||
[[ -f "$extractor" ]] || return 0
|
||||
if node "$extractor" --repo-root "$REPO_ROOT" >>"${OUTPUT_FILE:-/dev/stdout}" 2>/dev/null; then
|
||||
:
|
||||
else
|
||||
section app_config
|
||||
kv status "extract-app-config failed (is Psysonic installed / has it been run once?)"
|
||||
fi
|
||||
}
|
||||
|
||||
run_app_config_dump
|
||||
|
||||
section network_probe_hint
|
||||
kv note_1 "App server profiles and curl probes are in app_servers / app_network_probe sections above."
|
||||
kv note_2 "Passwords and custom header values are redacted; compare password_sha256 and value_sha256 only."
|
||||
kv note_3 "Compare two dumps with: scripts/compare-environment.sh a.txt b.txt"
|
||||
|
||||
if [[ -n "$OUTPUT_FILE" ]]; then
|
||||
echo "Wrote $OUTPUT_FILE" >&2
|
||||
fi
|
||||
@@ -1,91 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Extract the body of a ## [version] section from a Keep-a-Changelog-style file.
|
||||
* Resolution matches src/utils/releaseNotes/releaseNotesMatch.ts.
|
||||
*
|
||||
* Usage: node scripts/extract-release-section.mjs <file> <version> [--allow-empty]
|
||||
* Stdout: section body (no ## header). Exit 1 if empty unless --allow-empty.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
const SEMVER_CORE = /^v?(\d+\.\d+\.\d+)/i;
|
||||
|
||||
function versionCore(version) {
|
||||
const m = version.trim().match(SEMVER_CORE);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
function isPlainTriple(header) {
|
||||
return /^\d+\.\d+\.\d+$/.test(header.trim());
|
||||
}
|
||||
|
||||
function splitBlocks(raw) {
|
||||
return raw.split(/\n(?=## \[)/).filter((b) => b.startsWith('## ['));
|
||||
}
|
||||
|
||||
function headerVersion(block) {
|
||||
const m = block.match(/^## \[([^\]]+)\]/);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
function parseBlock(block) {
|
||||
const lines = block.split('\n');
|
||||
const m = lines[0].match(/## \[([^\]]+)\](?:\s*-\s*(.+))?/);
|
||||
if (!m) return null;
|
||||
return {
|
||||
headerVersion: m[1],
|
||||
date: (m[2] ?? '').trim(),
|
||||
body: lines.slice(1).join('\n').trim(),
|
||||
};
|
||||
}
|
||||
|
||||
export function findReleaseSection(raw, appVersion) {
|
||||
const blocks = splitBlocks(raw);
|
||||
|
||||
const exact = blocks.find((b) => b.startsWith(`## [${appVersion}]`));
|
||||
if (exact) return parseBlock(exact);
|
||||
|
||||
const appCore = versionCore(appVersion);
|
||||
if (!appCore) return null;
|
||||
|
||||
const candidates = blocks.filter((b) => {
|
||||
const hv = headerVersion(b);
|
||||
return hv !== null && versionCore(hv) === appCore;
|
||||
});
|
||||
if (candidates.length === 0) return null;
|
||||
|
||||
const plain = candidates.find((b) => {
|
||||
const hv = headerVersion(b);
|
||||
return hv !== null && isPlainTriple(hv);
|
||||
});
|
||||
return parseBlock(plain ?? candidates[0]);
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const allowEmpty = args.includes('--allow-empty');
|
||||
const positional = args.filter((a) => a !== '--allow-empty');
|
||||
const [file, version] = positional;
|
||||
|
||||
if (!file || !version) {
|
||||
console.error('Usage: node scripts/extract-release-section.mjs <file> <version> [--allow-empty]');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const raw = readFileSync(file, 'utf8');
|
||||
const entry = findReleaseSection(raw, version);
|
||||
const body = entry?.body?.trim() ?? '';
|
||||
|
||||
if (!body) {
|
||||
if (allowEmpty) process.exit(0);
|
||||
console.error(`No release section found in ${file} for version ${version}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.stdout.write(`${body}\n`);
|
||||
}
|
||||
|
||||
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
||||
if (isMain) main();
|
||||
@@ -1,26 +0,0 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { findReleaseSection } from './extract-release-section.mjs';
|
||||
|
||||
const FIXTURE = `
|
||||
## [1.48.0] - 2026-06-10
|
||||
|
||||
## Highlights
|
||||
- One
|
||||
|
||||
## [1.47.0]
|
||||
- Old
|
||||
`;
|
||||
|
||||
describe('findReleaseSection', () => {
|
||||
it('matches base line for -rc versions', () => {
|
||||
const entry = findReleaseSection(FIXTURE, '1.48.0-rc.3');
|
||||
assert.equal(entry.headerVersion, '1.48.0');
|
||||
assert.match(entry.body, /Highlights/);
|
||||
});
|
||||
|
||||
it('matches base line for -dev versions', () => {
|
||||
const entry = findReleaseSection(FIXTURE, '1.48.0-dev');
|
||||
assert.equal(entry.headerVersion, '1.48.0');
|
||||
});
|
||||
});
|
||||
@@ -1,252 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// Generates src/data/licenses.json — the data backing Settings → System → Open Source Licenses.
|
||||
//
|
||||
// Maintainer-only operation: run before each release to refresh the bundled
|
||||
// dependency list. Requires `cargo-about` installed globally
|
||||
// (`cargo install cargo-about --features cli`). The npm-side license data is
|
||||
// read via npx without persisting a devDependency, so package.json stays clean
|
||||
// and the nix-npm-deps-hash-sync workflow is not triggered.
|
||||
//
|
||||
// Output lives in src/ (not public/) so Vite bundles it as a lazy JS chunk
|
||||
// — no runtime fetch, the data is fixed into the build artifact.
|
||||
//
|
||||
// Output format (per entry):
|
||||
// {
|
||||
// name: string,
|
||||
// version: string,
|
||||
// source: 'npm' | 'cargo',
|
||||
// licenses: string[], // SPDX ids, may be multi (dual-licensed)
|
||||
// repository?: string,
|
||||
// homepage?: string,
|
||||
// description?: string,
|
||||
// publisher?: string,
|
||||
// licenseText?: string, // full license text, may be empty if not found
|
||||
// }
|
||||
//
|
||||
// Usage: node scripts/generate-licenses.mjs
|
||||
|
||||
import { execSync, spawnSync } from 'node:child_process';
|
||||
import { readFileSync, writeFileSync, existsSync, statSync, readdirSync, mkdirSync } from 'node:fs';
|
||||
import { resolve, dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = resolve(__dirname, '..');
|
||||
const TAURI_DIR = join(REPO_ROOT, 'src-tauri');
|
||||
const OUT_DIR = join(REPO_ROOT, 'src', 'data');
|
||||
const OUT_PATH = join(OUT_DIR, 'licenses.json');
|
||||
|
||||
function die(msg) {
|
||||
console.error(`\x1b[31m✗\x1b[0m ${msg}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function step(msg) {
|
||||
console.log(`\x1b[36m→\x1b[0m ${msg}`);
|
||||
}
|
||||
|
||||
function ok(msg) {
|
||||
console.log(`\x1b[32m✓\x1b[0m ${msg}`);
|
||||
}
|
||||
|
||||
// ── Cargo side via cargo-about ──────────────────────────────────────────────
|
||||
function checkCargoAbout() {
|
||||
try {
|
||||
execSync('cargo about --version', { stdio: 'ignore' });
|
||||
} catch {
|
||||
die(
|
||||
'cargo-about not found. Install with:\n' +
|
||||
' cargo install cargo-about --features cli',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function generateCargoLicenses() {
|
||||
step('Running cargo-about (this resolves the full dependency graph and may take ~30s)…');
|
||||
const result = spawnSync(
|
||||
'cargo',
|
||||
[
|
||||
'about',
|
||||
'generate',
|
||||
'about.hbs',
|
||||
'--config', 'about.toml',
|
||||
'--manifest-path', 'Cargo.toml',
|
||||
'--all-features',
|
||||
],
|
||||
{ cwd: TAURI_DIR, encoding: 'utf-8', maxBuffer: 64 * 1024 * 1024 },
|
||||
);
|
||||
if (result.status !== 0) {
|
||||
console.error(result.stderr);
|
||||
die('cargo-about failed.');
|
||||
}
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
if (!parsed?.licenses) die('cargo-about output missing `licenses` key.');
|
||||
|
||||
// Flatten: pivot from license-grouped to crate-grouped.
|
||||
const byCrate = new Map();
|
||||
for (const lic of parsed.licenses) {
|
||||
for (const u of lic.used_by ?? []) {
|
||||
const key = `${u.name}@${u.version}`;
|
||||
if (!byCrate.has(key)) {
|
||||
byCrate.set(key, {
|
||||
name: u.name,
|
||||
version: u.version,
|
||||
source: 'cargo',
|
||||
licenses: [],
|
||||
repository: u.repository || undefined,
|
||||
description: u.description || undefined,
|
||||
licenseText: '',
|
||||
});
|
||||
}
|
||||
const entry = byCrate.get(key);
|
||||
if (!entry.licenses.includes(lic.id)) entry.licenses.push(lic.id);
|
||||
// Append text — a crate may have multiple licenses (dual). Keep them all,
|
||||
// separated by a clear divider.
|
||||
if (lic.text) {
|
||||
if (entry.licenseText) {
|
||||
entry.licenseText += `\n\n--- ${lic.id} ---\n\n${lic.text}`;
|
||||
} else {
|
||||
entry.licenseText = lic.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ok(`cargo: ${byCrate.size} crates`);
|
||||
return [...byCrate.values()];
|
||||
}
|
||||
|
||||
// ── npm side via license-checker-rseidelsohn (npx) ──────────────────────────
|
||||
function readLicenseFile(path) {
|
||||
if (!path || !existsSync(path)) return '';
|
||||
try {
|
||||
return readFileSync(path, 'utf-8');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// Try to find a notice/copyright file alongside the license file.
|
||||
function findExtraLicenseTexts(packagePath) {
|
||||
if (!packagePath || !existsSync(packagePath)) return '';
|
||||
let extra = '';
|
||||
try {
|
||||
const entries = readdirSync(packagePath);
|
||||
for (const name of entries) {
|
||||
const upper = name.toUpperCase();
|
||||
if (
|
||||
upper === 'NOTICE' ||
|
||||
upper === 'NOTICE.MD' ||
|
||||
upper === 'NOTICE.TXT' ||
|
||||
upper === 'COPYRIGHT' ||
|
||||
upper === 'COPYRIGHT.MD' ||
|
||||
upper === 'COPYRIGHT.TXT'
|
||||
) {
|
||||
const full = join(packagePath, name);
|
||||
if (statSync(full).isFile()) {
|
||||
const txt = readFileSync(full, 'utf-8').trim();
|
||||
if (txt) extra += `\n\n--- ${name} ---\n\n${txt}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return extra;
|
||||
}
|
||||
|
||||
function generateNpmLicenses() {
|
||||
step('Running license-checker-rseidelsohn via npx (this enumerates node_modules)…');
|
||||
const result = spawnSync(
|
||||
'npx',
|
||||
[
|
||||
'--yes',
|
||||
'license-checker-rseidelsohn@latest',
|
||||
'--json',
|
||||
'--production',
|
||||
'--excludePrivatePackages',
|
||||
],
|
||||
{ cwd: REPO_ROOT, encoding: 'utf-8', maxBuffer: 256 * 1024 * 1024 },
|
||||
);
|
||||
if (result.status !== 0) {
|
||||
console.error(result.stderr);
|
||||
die('license-checker-rseidelsohn failed.');
|
||||
}
|
||||
const data = JSON.parse(result.stdout);
|
||||
const out = [];
|
||||
for (const [key, info] of Object.entries(data)) {
|
||||
// key looks like `package-name@1.2.3` or `@scope/name@1.2.3`.
|
||||
const at = key.lastIndexOf('@');
|
||||
if (at <= 0) continue;
|
||||
const name = key.slice(0, at);
|
||||
const version = key.slice(at + 1);
|
||||
const licenses = Array.isArray(info.licenses)
|
||||
? info.licenses
|
||||
: typeof info.licenses === 'string'
|
||||
? [info.licenses]
|
||||
: [];
|
||||
const text =
|
||||
readLicenseFile(info.licenseFile) +
|
||||
findExtraLicenseTexts(info.path);
|
||||
out.push({
|
||||
name,
|
||||
version,
|
||||
source: 'npm',
|
||||
licenses: licenses.map(String),
|
||||
repository: info.repository || undefined,
|
||||
homepage: info.url || undefined,
|
||||
publisher: info.publisher || undefined,
|
||||
description: undefined, // license-checker doesn't surface descriptions
|
||||
licenseText: text.trim(),
|
||||
});
|
||||
}
|
||||
ok(`npm: ${out.length} packages`);
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── Self ────────────────────────────────────────────────────────────────────
|
||||
function readSelfPackageJson() {
|
||||
const pkg = JSON.parse(readFileSync(join(REPO_ROOT, 'package.json'), 'utf-8'));
|
||||
return {
|
||||
name: pkg.name ?? 'psysonic',
|
||||
version: pkg.version ?? '0.0.0',
|
||||
};
|
||||
}
|
||||
|
||||
// ── Main ────────────────────────────────────────────────────────────────────
|
||||
function main() {
|
||||
step(`Generating ${OUT_PATH.replace(REPO_ROOT + '/', '')}`);
|
||||
mkdirSync(OUT_DIR, { recursive: true });
|
||||
checkCargoAbout();
|
||||
const cargoEntries = generateCargoLicenses();
|
||||
const npmEntries = generateNpmLicenses();
|
||||
|
||||
const entries = [...cargoEntries, ...npmEntries];
|
||||
// Stable sort: name, then version. Case-insensitive on name.
|
||||
entries.sort((a, b) => {
|
||||
const n = a.name.toLowerCase().localeCompare(b.name.toLowerCase());
|
||||
if (n !== 0) return n;
|
||||
return a.version.localeCompare(b.version);
|
||||
});
|
||||
|
||||
const self = readSelfPackageJson();
|
||||
const stats = {
|
||||
npm: npmEntries.length,
|
||||
cargo: cargoEntries.length,
|
||||
total: entries.length,
|
||||
withFullText: entries.filter((e) => e.licenseText && e.licenseText.length > 0).length,
|
||||
};
|
||||
|
||||
const payload = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
project: self,
|
||||
stats,
|
||||
entries,
|
||||
};
|
||||
|
||||
writeFileSync(OUT_PATH, JSON.stringify(payload, null, 0) + '\n');
|
||||
const sizeKb = (statSync(OUT_PATH).size / 1024).toFixed(1);
|
||||
ok(`Wrote ${OUT_PATH} (${stats.total} entries, ${sizeKb} KB)`);
|
||||
ok(` ${stats.cargo} cargo + ${stats.npm} npm; ${stats.withFullText} with full license text`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,57 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Build src/generated/releaseNotesBundle.ts for production bundles.
|
||||
* Embeds only the ## [X.Y.Z] slice for package.json version (dev, RC, and stable).
|
||||
* tauri:dev reads live markdown from the repo via Vite ?raw imports instead.
|
||||
*/
|
||||
|
||||
import { readFileSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { findReleaseSection } from './extract-release-section.mjs';
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
|
||||
const version = pkg.version;
|
||||
|
||||
const whatsNewPath = join(root, 'WHATS_NEW.md');
|
||||
const changelogPath = join(root, 'CHANGELOG.md');
|
||||
const whatsNewFull = readFileSync(whatsNewPath, 'utf8');
|
||||
const changelogFull = readFileSync(changelogPath, 'utf8');
|
||||
|
||||
function sliceForVersion(full, fileLabel) {
|
||||
const entry = findReleaseSection(full, version);
|
||||
if (!entry?.body) {
|
||||
console.warn(`warn: no section in ${fileLabel} for ${version} — embedding empty slice`);
|
||||
return '';
|
||||
}
|
||||
const dateSuffix = entry.date ? ` - ${entry.date}` : '';
|
||||
return `## [${entry.headerVersion}]${dateSuffix}\n\n${entry.body}`;
|
||||
}
|
||||
|
||||
const whatsNewRaw = sliceForVersion(whatsNewFull, 'WHATS_NEW.md');
|
||||
const changelogRaw = sliceForVersion(changelogFull, 'CHANGELOG.md');
|
||||
|
||||
const outDir = join(root, 'src/generated');
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
|
||||
const ts = `/** @generated — run: node scripts/generate-release-notes-bundle.mjs */
|
||||
export const WHATS_NEW_RAW: string = ${JSON.stringify(whatsNewRaw)};
|
||||
|
||||
export const CHANGELOG_RAW: string = ${JSON.stringify(changelogRaw)};
|
||||
`;
|
||||
|
||||
writeFileSync(join(outDir, 'releaseNotesBundle.ts'), ts, 'utf8');
|
||||
console.log(`wrote src/generated/releaseNotesBundle.ts (sliced for ${version})`);
|
||||
|
||||
// Leaf module for boot-critical client id — must not import package.json at runtime
|
||||
// in the authStore chunk (circular init → psysonic/undefined on Windows WebView2).
|
||||
const appVersionTs = `/** @generated — run: node scripts/generate-release-notes-bundle.mjs */
|
||||
export const APP_VERSION = ${JSON.stringify(version)};
|
||||
|
||||
/** Subsonic REST \`c\` param and OpenSubsonic client id (\`psysonic/<version>\`). */
|
||||
export const SUBSONIC_CLIENT_ID = ${JSON.stringify(`psysonic/${version}`)};
|
||||
`;
|
||||
|
||||
writeFileSync(join(outDir, 'appVersion.ts'), appVersionTs, 'utf8');
|
||||
console.log(`wrote src/generated/appVersion.ts (${version})`);
|
||||
@@ -1,89 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// Generates latest.json for the Tauri updater from a GitHub release.
|
||||
// Reads .sig files uploaded by tauri-action, assembles the manifest, writes latest.json.
|
||||
//
|
||||
// macOS-only for now — Windows + Linux are added once their signing pipelines
|
||||
// (Certum cert for Windows, native package managers for Linux) are wired up.
|
||||
//
|
||||
// Required env vars: VERSION, GITHUB_TOKEN
|
||||
// Usage: node scripts/generate-update-manifest.js
|
||||
|
||||
const { execSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
|
||||
const VERSION = process.env.VERSION;
|
||||
const REPO = 'Psychotoxical/psysonic';
|
||||
const TAG = `app-v${VERSION}`;
|
||||
|
||||
if (!VERSION) {
|
||||
console.error('VERSION env var required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Platform → update bundle filename (produced by tauri-action with updater plugin)
|
||||
const PLATFORM_FILES = {
|
||||
'darwin-aarch64': 'Psysonic_aarch64.app.tar.gz',
|
||||
'darwin-x86_64': 'Psysonic_x64.app.tar.gz',
|
||||
};
|
||||
|
||||
const platforms = {};
|
||||
|
||||
// A real minisign .sig file is multi-line and ~200+ chars.
|
||||
// A public key (RWTxxx... single line, ~56 chars) must never appear here.
|
||||
function validateSignature(sig, platform, sigFile) {
|
||||
if (/^RWT[A-Za-z0-9+/]{10,}={0,2}$/.test(sig)) {
|
||||
throw new Error(
|
||||
`${platform}: .sig file "${sigFile}" contains a PUBLIC KEY instead of a signature.\n` +
|
||||
` Got: ${sig}\n` +
|
||||
` TAURI_SIGNING_PRIVATE_KEY must be the private key, not the public one.`
|
||||
);
|
||||
}
|
||||
if (sig.length < 80) {
|
||||
throw new Error(
|
||||
`${platform}: .sig file "${sigFile}" looks too short (${sig.length} chars) to be a valid signature.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [platform, filename] of Object.entries(PLATFORM_FILES)) {
|
||||
const sigFile = `${filename}.sig`;
|
||||
try {
|
||||
execSync(
|
||||
`gh release download "${TAG}" --repo "${REPO}" -p "${sigFile}" --clobber`,
|
||||
{ stdio: 'pipe' }
|
||||
);
|
||||
const signature = fs.readFileSync(sigFile, 'utf8').trim();
|
||||
validateSignature(signature, platform, sigFile);
|
||||
const url = `https://github.com/${REPO}/releases/download/${TAG}/${filename}`;
|
||||
platforms[platform] = { signature, url };
|
||||
console.log(`✓ ${platform}`);
|
||||
} catch (e) {
|
||||
console.warn(`⚠ Skipping ${platform}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(platforms).length === 0) {
|
||||
console.error('No platforms found — aborting manifest generation');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let notes = '';
|
||||
try {
|
||||
const raw = execSync(
|
||||
`gh release view "${TAG}" --repo "${REPO}" --json body`,
|
||||
{ stdio: 'pipe' }
|
||||
).toString();
|
||||
notes = JSON.parse(raw).body ?? '';
|
||||
} catch {
|
||||
console.warn('Could not fetch release notes');
|
||||
}
|
||||
|
||||
const manifest = {
|
||||
version: VERSION,
|
||||
notes,
|
||||
pub_date: new Date().toISOString(),
|
||||
platforms,
|
||||
};
|
||||
|
||||
fs.writeFileSync('latest.json', JSON.stringify(manifest, null, 2));
|
||||
console.log(`\nWrote latest.json for v${VERSION} with platforms: ${Object.keys(platforms).join(', ')}`);
|
||||
@@ -1,182 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Psysonic Auto-Installer
|
||||
# Automatically detects your OS and installs the latest release from GitHub
|
||||
|
||||
REPO="Psychotoxical/psysonic"
|
||||
APP_NAME="psysonic"
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Log helpers write to stderr so functions that return values via stdout
|
||||
# (e.g. get_download_url) stay clean when called in command substitution.
|
||||
info() {
|
||||
echo -e "${BLUE}[INFO]${NC} $1" >&2
|
||||
}
|
||||
|
||||
success() {
|
||||
echo -e "${GREEN}[SUCCESS]${NC} $1" >&2
|
||||
}
|
||||
|
||||
warn() {
|
||||
echo -e "${YELLOW}[WARN]${NC} $1" >&2
|
||||
}
|
||||
|
||||
error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Check if running as root
|
||||
check_root() {
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
error "Please run this script as root (use sudo)"
|
||||
fi
|
||||
}
|
||||
|
||||
# Detect package manager and OS type
|
||||
detect_os() {
|
||||
if command -v apt &> /dev/null; then
|
||||
OS_TYPE="debian"
|
||||
PACKAGE_MANAGER="apt"
|
||||
info "Detected Debian/Ubuntu-based system (apt)"
|
||||
elif command -v dnf &> /dev/null; then
|
||||
OS_TYPE="rhel"
|
||||
PACKAGE_MANAGER="dnf"
|
||||
info "Detected RHEL/Fedora-based system (dnf)"
|
||||
elif command -v yum &> /dev/null; then
|
||||
OS_TYPE="rhel"
|
||||
PACKAGE_MANAGER="yum"
|
||||
info "Detected RHEL/CentOS-based system (yum)"
|
||||
else
|
||||
error "Unsupported package manager. This installer supports Debian/Ubuntu and RHEL/Fedora/CentOS systems."
|
||||
fi
|
||||
}
|
||||
|
||||
# Get the latest release download URL for the specific package type
|
||||
get_download_url() {
|
||||
local api_url="https://api.github.com/repos/${REPO}/releases/latest"
|
||||
|
||||
info "Fetching latest release information..."
|
||||
|
||||
local release_info
|
||||
release_info=$(curl -s "$api_url")
|
||||
|
||||
if echo "$release_info" | grep -q "message.*Not Found"; then
|
||||
error "Could not fetch release information. Please check your internet connection."
|
||||
fi
|
||||
|
||||
local tag_name
|
||||
tag_name=$(echo "$release_info" | grep -o '"tag_name": *"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||
|
||||
if [ -z "$tag_name" ]; then
|
||||
error "Could not determine latest release version."
|
||||
fi
|
||||
|
||||
info "Latest version: $tag_name"
|
||||
|
||||
local download_url=""
|
||||
|
||||
if [ "$OS_TYPE" = "debian" ]; then
|
||||
download_url=$(echo "$release_info" | grep -o '"browser_download_url": *"[^"]*\.deb"' | head -1 | cut -d'"' -f4)
|
||||
elif [ "$OS_TYPE" = "rhel" ]; then
|
||||
download_url=$(echo "$release_info" | grep -o '"browser_download_url": *"[^"]*\.rpm"' | head -1 | cut -d'"' -f4)
|
||||
fi
|
||||
|
||||
if [ -z "$download_url" ]; then
|
||||
error "Could not find download URL for $OS_TYPE package."
|
||||
fi
|
||||
|
||||
echo "$download_url"
|
||||
}
|
||||
|
||||
# Install the package
|
||||
install_package() {
|
||||
local download_url="$1"
|
||||
local temp_dir
|
||||
temp_dir=$(mktemp -d)
|
||||
local package_file="$temp_dir/${APP_NAME}_latest"
|
||||
|
||||
info "Downloading package..."
|
||||
|
||||
if [ "$OS_TYPE" = "debian" ]; then
|
||||
package_file="${package_file}.deb"
|
||||
curl --fail --globoff -L -o "$package_file" "$download_url"
|
||||
|
||||
info "Installing package..."
|
||||
$PACKAGE_MANAGER install -y "$package_file" || {
|
||||
warn "Trying to fix broken dependencies..."
|
||||
$PACKAGE_MANAGER install -f -y
|
||||
}
|
||||
elif [ "$OS_TYPE" = "rhel" ]; then
|
||||
package_file="${package_file}.rpm"
|
||||
curl --fail --globoff -L -o "$package_file" "$download_url"
|
||||
|
||||
info "Installing package..."
|
||||
$PACKAGE_MANAGER install -y "$package_file"
|
||||
fi
|
||||
|
||||
# Cleanup
|
||||
rm -rf "$temp_dir"
|
||||
}
|
||||
|
||||
# Check if app is already installed
|
||||
check_installed() {
|
||||
if command -v $APP_NAME &> /dev/null || command -v ${APP_NAME^} &> /dev/null; then
|
||||
warn "${APP_NAME} appears to be already installed."
|
||||
# Under `curl ... | bash`, stdin is the script stream itself, so
|
||||
# read the answer from the controlling terminal instead. Probe by
|
||||
# opening: `[ -r /dev/tty ]` passes on the 0666 device node even
|
||||
# without a controlling terminal; only open() reports the failure.
|
||||
if { : < /dev/tty; } 2>/dev/null; then
|
||||
read -p "Do you want to reinstall? (y/N): " -n 1 -r < /dev/tty
|
||||
echo
|
||||
else
|
||||
warn "No terminal available for prompt; skipping reinstall."
|
||||
exit 0
|
||||
fi
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
info "Installation cancelled."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Main installation flow
|
||||
main() {
|
||||
echo -e "${GREEN}"
|
||||
echo "╔══════════════════════════════════════════════════════════╗"
|
||||
echo "║ Psysonic Auto-Installer ║"
|
||||
echo "║ Install the latest release from GitHub Releases ║"
|
||||
echo "╚══════════════════════════════════════════════════════════╝"
|
||||
echo -e "${NC}"
|
||||
|
||||
check_root
|
||||
detect_os
|
||||
check_installed
|
||||
|
||||
local download_url
|
||||
download_url=$(get_download_url)
|
||||
|
||||
if [ -z "$download_url" ]; then
|
||||
error "Failed to get download URL."
|
||||
fi
|
||||
|
||||
info "Download URL: $download_url"
|
||||
|
||||
install_package "$download_url"
|
||||
|
||||
echo ""
|
||||
success "Psysonic has been installed successfully!"
|
||||
echo -e "${BLUE}You can launch it from your application menu or by running:${NC} psysonic"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Run main function
|
||||
main
|
||||
@@ -1,249 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Read Psysonic app config from WebKit localStorage + XDG dirs.
|
||||
* Emits key=value lines (passwords/secrets redacted; header values hashed).
|
||||
*
|
||||
* Usage: node scripts/lib/extract-app-config.mjs [--app-id ID] [--repo-root PATH]
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = { appId: process.env.PSYSONIC_APP_ID || '', repoRoot: '' };
|
||||
for (let i = 2; i < argv.length; i++) {
|
||||
if (argv[i] === '--app-id' && argv[i + 1]) {
|
||||
out.appId = argv[++i];
|
||||
} else if (argv[i] === '--repo-root' && argv[i + 1]) {
|
||||
out.repoRoot = argv[++i];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function sha256(text) {
|
||||
return createHash('sha256').update(text, 'utf8').digest('hex').slice(0, 16);
|
||||
}
|
||||
|
||||
function emitSection(name) {
|
||||
process.stdout.write(`\n[${name}]\n`);
|
||||
}
|
||||
|
||||
function emit(key, value) {
|
||||
const v = String(value ?? '').replace(/\n/g, '\\n');
|
||||
process.stdout.write(`${key}=${v}\n`);
|
||||
}
|
||||
|
||||
function readAppIdFromRepo(repoRoot) {
|
||||
const conf = path.join(repoRoot, 'src-tauri', 'tauri.conf.json');
|
||||
if (!fs.existsSync(conf)) return '';
|
||||
try {
|
||||
const j = JSON.parse(fs.readFileSync(conf, 'utf8'));
|
||||
return typeof j.identifier === 'string' ? j.identifier : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function xdgDataHome() {
|
||||
return process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share');
|
||||
}
|
||||
|
||||
function xdgConfigHome() {
|
||||
return process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
|
||||
}
|
||||
|
||||
function decodeWebKitValue(raw) {
|
||||
if (raw == null) return null;
|
||||
const buf = Buffer.isBuffer(raw) ? raw : raw instanceof Uint8Array ? Buffer.from(raw) : null;
|
||||
if (buf) {
|
||||
if (buf.length >= 2 && buf[0] === 0xff && buf[1] === 0xfe) {
|
||||
return new TextDecoder('utf-16le').decode(buf.subarray(2));
|
||||
}
|
||||
if (buf.length >= 2 && buf[0] === 0xfe && buf[1] === 0xff) {
|
||||
return new TextDecoder('utf-16be').decode(buf.subarray(2));
|
||||
}
|
||||
// WebKit often stores UTF-16LE without BOM
|
||||
if (buf.length >= 4 && buf[1] === 0 && buf[3] === 0) {
|
||||
return new TextDecoder('utf-16le').decode(buf);
|
||||
}
|
||||
return buf.toString('utf8');
|
||||
}
|
||||
if (typeof raw === 'string') return raw;
|
||||
return String(raw);
|
||||
}
|
||||
|
||||
function readLocalStorageRaw(dbPath, storageKey) {
|
||||
try {
|
||||
const db = new DatabaseSync(dbPath, { readOnly: true });
|
||||
const row = db.prepare('SELECT value FROM ItemTable WHERE key = ?').get(storageKey);
|
||||
db.close();
|
||||
if (!row?.value) return null;
|
||||
return decodeWebKitValue(row.value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readLocalStorageKey(dbPath, storageKey) {
|
||||
const text = readLocalStorageRaw(dbPath, storageKey);
|
||||
if (text == null) return null;
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
function pickLocalStorageFile(dataDir) {
|
||||
const dir = path.join(dataDir, 'localstorage');
|
||||
if (!fs.existsSync(dir)) return null;
|
||||
const files = fs
|
||||
.readdirSync(dir)
|
||||
.filter(f => f.endsWith('.localstorage') && !f.includes('-wal') && !f.includes('-shm'))
|
||||
.map(f => path.join(dir, f));
|
||||
if (files.length === 0) return null;
|
||||
|
||||
// Prefer packaged app origin over vite dev (1420) when both exist.
|
||||
const ranked = files.sort((a, b) => {
|
||||
const score = p => {
|
||||
const base = path.basename(p);
|
||||
if (base.includes('tauri_localhost')) return 0;
|
||||
if (base.includes('1420')) return 2;
|
||||
return 1;
|
||||
};
|
||||
return score(a) - score(b);
|
||||
});
|
||||
|
||||
for (const file of ranked) {
|
||||
const auth = readLocalStorageKey(file, 'psysonic-auth');
|
||||
if (auth?.state?.servers?.length) return file;
|
||||
}
|
||||
return ranked[0];
|
||||
}
|
||||
|
||||
function probeHttpReachability(rawUrl) {
|
||||
if (!rawUrl) return 'empty';
|
||||
const url = rawUrl.startsWith('http') ? rawUrl : `http://${rawUrl}`;
|
||||
const r = spawnSync(
|
||||
'curl',
|
||||
['-sS', '-o', '/dev/null', '-w', '%{http_code}', '--connect-timeout', '5', '--max-time', '10', url],
|
||||
{ encoding: 'utf8', timeout: 15000 },
|
||||
);
|
||||
if (r.error) return `error:${r.error.code ?? 'unknown'}`;
|
||||
if (r.status !== 0) return `curl_exit_${r.status}`;
|
||||
return `http_${r.stdout.trim() || '000'}`;
|
||||
}
|
||||
|
||||
function dumpNetworkProbes(servers) {
|
||||
emitSection('app_network_probe');
|
||||
const seen = new Set();
|
||||
servers.forEach((srv, i) => {
|
||||
for (const [kind, raw] of [
|
||||
['url', srv.url],
|
||||
['alternateUrl', srv.alternateUrl],
|
||||
]) {
|
||||
if (!raw || seen.has(raw)) continue;
|
||||
seen.add(raw);
|
||||
emit(`probe.${i}.${kind}`, probeHttpReachability(raw));
|
||||
emit(`probe.${i}.${kind}_target`, raw);
|
||||
}
|
||||
});
|
||||
if (seen.size === 0) emit('probe_status', 'no server URLs configured');
|
||||
}
|
||||
|
||||
function dumpServerProfiles(state) {
|
||||
const servers = state?.servers ?? [];
|
||||
emit('active_server_id', state?.activeServerId ?? '');
|
||||
emit('server_count', servers.length);
|
||||
servers.forEach((srv, i) => {
|
||||
const p = `server.${i}`;
|
||||
emit(`${p}.id`, srv.id ?? '');
|
||||
emit(`${p}.name`, srv.name ?? '');
|
||||
emit(`${p}.url`, srv.url ?? '');
|
||||
emit(`${p}.alternateUrl`, srv.alternateUrl ?? '');
|
||||
emit(`${p}.shareUsesLocalUrl`, srv.shareUsesLocalUrl === true ? 'true' : 'false');
|
||||
emit(`${p}.username`, srv.username ?? '');
|
||||
emit(`${p}.password_set`, srv.password ? 'yes' : 'no');
|
||||
emit(`${p}.password_sha256`, srv.password ? sha256(srv.password) : 'none');
|
||||
emit(`${p}.customHeadersApplyTo`, srv.customHeadersApplyTo ?? 'public');
|
||||
const headers = srv.customHeaders ?? [];
|
||||
emit(`${p}.customHeaders_count`, headers.length);
|
||||
headers.forEach((h, hi) => {
|
||||
emit(`${p}.customHeaders.${hi}.name`, h.name ?? '');
|
||||
emit(`${p}.customHeaders.${hi}.value_sha256`, h.value ? sha256(h.value) : 'empty');
|
||||
});
|
||||
});
|
||||
dumpNetworkProbes(servers);
|
||||
}
|
||||
|
||||
function fileMeta(label, filePath) {
|
||||
if (!filePath || !fs.existsSync(filePath)) {
|
||||
emit(`${label}_exists`, 'no');
|
||||
return;
|
||||
}
|
||||
const st = fs.statSync(filePath);
|
||||
emit(`${label}_exists`, 'yes');
|
||||
emit(`${label}_path`, filePath);
|
||||
emit(`${label}_size`, st.size);
|
||||
emit(`${label}_mtime`, st.mtime.toISOString());
|
||||
}
|
||||
|
||||
function listDir(label, dirPath, max = 12) {
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
emit(`${label}_exists`, 'no');
|
||||
return;
|
||||
}
|
||||
emit(`${label}_exists`, 'yes');
|
||||
emit(`${label}_path`, dirPath);
|
||||
const entries = fs.readdirSync(dirPath).sort();
|
||||
emit(`${label}_entry_count`, entries.length);
|
||||
entries.slice(0, max).forEach((name, i) => emit(`${label}.entry.${i}`, name));
|
||||
}
|
||||
|
||||
const { appId: appIdArg, repoRoot } = parseArgs(process.argv);
|
||||
let appId = appIdArg || (repoRoot ? readAppIdFromRepo(repoRoot) : '');
|
||||
if (!appId) appId = readAppIdFromRepo(process.cwd()) || 'dev.psysonic.player';
|
||||
|
||||
const dataDir = path.join(xdgDataHome(), appId);
|
||||
const configDir = path.join(xdgConfigHome(), appId);
|
||||
|
||||
emitSection('app_config_paths');
|
||||
emit('app_id', appId);
|
||||
emit('data_dir', dataDir);
|
||||
emit('config_dir', configDir);
|
||||
emit('data_dir_exists', fs.existsSync(dataDir) ? 'yes' : 'no');
|
||||
emit('config_dir_exists', fs.existsSync(configDir) ? 'yes' : 'no');
|
||||
|
||||
const lsFile = pickLocalStorageFile(dataDir);
|
||||
fileMeta('localstorage_db', lsFile);
|
||||
|
||||
emitSection('app_preferences');
|
||||
if (lsFile) {
|
||||
const lang = readLocalStorageRaw(lsFile, 'psysonic_language');
|
||||
emit('language', lang ?? 'unknown');
|
||||
|
||||
const authWrap = readLocalStorageKey(lsFile, 'psysonic-auth');
|
||||
if (authWrap?.state) {
|
||||
emitSection('app_servers');
|
||||
dumpServerProfiles(authWrap.state);
|
||||
} else {
|
||||
emit('app_servers_status', 'psysonic-auth not found or empty');
|
||||
}
|
||||
} else {
|
||||
emit('app_servers_status', 'no localstorage database found');
|
||||
}
|
||||
|
||||
emitSection('app_config_files');
|
||||
for (const rel of ['linux_wayland_text_profile', 'mini_player_pos.json', '.window-state.json']) {
|
||||
fileMeta(rel.replace(/\./g, '_'), path.join(configDir, rel));
|
||||
}
|
||||
|
||||
emitSection('app_data_artifacts');
|
||||
fileMeta('hsts_storage', path.join(dataDir, 'hsts-storage.sqlite'));
|
||||
fileMeta('library_db', path.join(dataDir, 'databases', 'library', 'library.sqlite'));
|
||||
listDir('localstorage_dir', path.join(dataDir, 'localstorage'));
|
||||
@@ -1,54 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Align src-tauri/Cargo.toml, src-tauri/tauri.conf.json, and workspace entries in
|
||||
* src-tauri/Cargo.lock with package.json "version".
|
||||
* Used after npm version in promote workflows so bundle names match release semver.
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const root = path.join(__dirname, '..');
|
||||
const version = require(path.join(root, 'package.json')).version;
|
||||
if (!version || typeof version !== 'string') {
|
||||
console.error('package.json version missing');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const cargoPath = path.join(root, 'src-tauri', 'Cargo.toml');
|
||||
let cargo = fs.readFileSync(cargoPath, 'utf8');
|
||||
if (!/^version = "[^"]*"$/m.test(cargo)) {
|
||||
console.error('Cargo.toml: expected a package-level line: version = "..."');
|
||||
process.exit(1);
|
||||
}
|
||||
cargo = cargo.replace(/^version = "[^"]*"$/m, `version = "${version}"`);
|
||||
fs.writeFileSync(cargoPath, cargo);
|
||||
console.log(`Cargo.toml -> ${version}`);
|
||||
|
||||
const confPath = path.join(root, 'src-tauri', 'tauri.conf.json');
|
||||
const conf = JSON.parse(fs.readFileSync(confPath, 'utf8'));
|
||||
conf.version = version;
|
||||
fs.writeFileSync(confPath, JSON.stringify(conf, null, 2) + '\n');
|
||||
console.log(`tauri.conf.json -> ${version}`);
|
||||
|
||||
/** @param {string} lockText */
|
||||
function syncCargoLockWorkspaceVersions(lockText, targetVersion) {
|
||||
return lockText.replace(
|
||||
/^(name = "psysonic[^"]*"\nversion = ")[^"]+"/gm,
|
||||
`$1${targetVersion}"`,
|
||||
);
|
||||
}
|
||||
|
||||
const lockPath = path.join(root, 'src-tauri', 'Cargo.lock');
|
||||
let lock = fs.readFileSync(lockPath, 'utf8');
|
||||
const updatedLock = syncCargoLockWorkspaceVersions(lock, version);
|
||||
if (updatedLock !== lock) {
|
||||
fs.writeFileSync(lockPath, updatedLock);
|
||||
console.log(`Cargo.lock workspace crates -> ${version}`);
|
||||
} else {
|
||||
console.log(`Cargo.lock workspace crates already at ${version}`);
|
||||
}
|
||||
|
||||
require('child_process').execSync('node scripts/sync-wix-bundle-version.mjs', {
|
||||
cwd: root,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
@@ -1,84 +0,0 @@
|
||||
/**
|
||||
* Integration tests for promotion/version sync: npm version → sync-tauri → sync-wix.
|
||||
* Simulates tauri.conf.json state after the full pipeline (no file I/O).
|
||||
*/
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import {
|
||||
wixMappedBuildNumber,
|
||||
wixVersionOverrideForPackageVersion,
|
||||
} from './wix-bundle-version.mjs';
|
||||
|
||||
/** Mirrors sync-tauri + sync-wix effects on tauri.conf.json. */
|
||||
function confAfterVersionSync(packageVersion, priorConf) {
|
||||
const conf = structuredClone(priorConf);
|
||||
conf.version = packageVersion;
|
||||
conf.bundle ??= {};
|
||||
conf.bundle.windows ??= {};
|
||||
const override = wixVersionOverrideForPackageVersion(packageVersion);
|
||||
conf.bundle.windows.wix = { ...(conf.bundle.windows.wix ?? {}), version: override };
|
||||
return conf;
|
||||
}
|
||||
|
||||
const baseConf = {
|
||||
version: '1.49.0-dev',
|
||||
bundle: {
|
||||
windows: {
|
||||
nsis: { installMode: 'currentUser' },
|
||||
wix: { version: '1.49.0.1' },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe('version promotion pipeline (sync-tauri + sync-wix)', () => {
|
||||
it('main → next: dev to first RC increases WiX build', () => {
|
||||
const conf = confAfterVersionSync('1.50.0-rc.1', baseConf);
|
||||
assert.equal(conf.version, '1.50.0-rc.1');
|
||||
assert.equal(conf.bundle.windows.wix.version, '1.50.0.10001');
|
||||
assert.ok(
|
||||
wixMappedBuildNumber('1.50.0-rc.1') > wixMappedBuildNumber('1.50.0-dev'),
|
||||
);
|
||||
});
|
||||
|
||||
it('next RC bump: rc.1 to rc.2', () => {
|
||||
const from = confAfterVersionSync('1.50.0-rc.1', baseConf);
|
||||
const conf = confAfterVersionSync('1.50.0-rc.2', from);
|
||||
assert.equal(conf.version, '1.50.0-rc.2');
|
||||
assert.equal(conf.bundle.windows.wix.version, '1.50.0.10002');
|
||||
});
|
||||
|
||||
it('next → release: RC to stable increases WiX build', () => {
|
||||
const from = confAfterVersionSync('1.50.0-rc.3', baseConf);
|
||||
const conf = confAfterVersionSync('1.50.0', from);
|
||||
assert.equal(conf.version, '1.50.0');
|
||||
assert.equal(conf.bundle.windows.wix.version, '1.50.0.65534');
|
||||
assert.ok(wixMappedBuildNumber('1.50.0') > wixMappedBuildNumber('1.50.0-rc.3'));
|
||||
});
|
||||
|
||||
it('post-release: next minor dev resets build band on new line', () => {
|
||||
const from = confAfterVersionSync('1.50.0', baseConf);
|
||||
const conf = confAfterVersionSync('1.51.0-dev', from);
|
||||
assert.equal(conf.version, '1.51.0-dev');
|
||||
assert.equal(conf.bundle.windows.wix.version, '1.51.0.1');
|
||||
});
|
||||
|
||||
it('stable release sets highest build in line', () => {
|
||||
const conf = confAfterVersionSync('2.0.0', {
|
||||
version: '2.0.0-rc.1',
|
||||
bundle: { windows: { wix: { version: '2.0.0.10001' } } },
|
||||
});
|
||||
assert.equal(conf.version, '2.0.0');
|
||||
assert.equal(conf.bundle.windows.wix.version, '2.0.0.65534');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sync-tauri-version-from-package.js invokes sync-wix', () => {
|
||||
it('calls sync-wix-bundle-version.mjs after updating conf.version', () => {
|
||||
const source = readFileSync(new URL('./sync-tauri-version-from-package.js', import.meta.url), 'utf8');
|
||||
assert.match(source, /sync-wix-bundle-version\.mjs/);
|
||||
const wixCall = source.indexOf('sync-wix-bundle-version');
|
||||
const versionWrite = source.indexOf('conf.version = version');
|
||||
assert.ok(wixCall > versionWrite, 'sync-wix must run after conf.version is set');
|
||||
});
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Write bundle.windows.wix.version in tauri.conf.json from package.json.
|
||||
* Keeps the top-level app version unchanged (About, updater metadata, filenames).
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { wixVersionOverrideForPackageVersion } from './wix-bundle-version.mjs';
|
||||
|
||||
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const version = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version;
|
||||
if (!version || typeof version !== 'string') {
|
||||
console.error('package.json version missing');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const confPath = path.join(root, 'src-tauri', 'tauri.conf.json');
|
||||
const conf = JSON.parse(fs.readFileSync(confPath, 'utf8'));
|
||||
conf.bundle ??= {};
|
||||
conf.bundle.windows ??= {};
|
||||
conf.bundle.windows.wix ??= {};
|
||||
|
||||
const wixVersion = wixVersionOverrideForPackageVersion(version);
|
||||
conf.bundle.windows.wix.version = wixVersion;
|
||||
console.log(`tauri.conf wix.version -> ${wixVersion} (package ${version})`);
|
||||
|
||||
fs.writeFileSync(confPath, `${JSON.stringify(conf, null, 2)}\n`);
|
||||
@@ -1,96 +0,0 @@
|
||||
/**
|
||||
* Map package.json semver to a monotonic WiX/MSI ProductVersion for Tauri.
|
||||
*
|
||||
* `bundle.windows.wix.version` must be `major.minor.patch.build` (four integers
|
||||
* ≤ 65535). Alphabetic pre-releases cannot be used directly. NSIS accepts full
|
||||
* semver without this mapping.
|
||||
*
|
||||
* Build bands (monotonic within X.Y.Z so in-place MSI upgrades work across
|
||||
* dev → rc → stable):
|
||||
* dev → .1
|
||||
* rc.N → .10000 + N
|
||||
* stable → .65534
|
||||
*
|
||||
* Display / About still use the real package.json version.
|
||||
*/
|
||||
|
||||
/** @type {const} */
|
||||
export const WIX_BUILD = {
|
||||
DEV: 1,
|
||||
RC_BASE: 10_000,
|
||||
STABLE: 65_534,
|
||||
};
|
||||
|
||||
const MAX_WIX_FIELD = 65_535;
|
||||
|
||||
/** @param {number} build */
|
||||
function assertBuildField(build, label) {
|
||||
if (!Number.isInteger(build) || build < 0 || build > MAX_WIX_FIELD) {
|
||||
throw new Error(`WiX build field out of range for ${label}: ${build}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WiX dot version for bundle.windows.wix.version (always four parts for channels
|
||||
* we ship).
|
||||
* @param {string} version
|
||||
*/
|
||||
export function wixVersionOverrideForPackageVersion(version) {
|
||||
const trimmed = version.trim();
|
||||
const match = trimmed.match(/^(\d+)\.(\d+)\.(\d+)(?:-([^+]+))?(?:\+(\d+))?$/);
|
||||
if (!match) {
|
||||
throw new Error(`Invalid semver for WiX mapping: ${trimmed}`);
|
||||
}
|
||||
|
||||
const major = match[1];
|
||||
const minor = match[2];
|
||||
const patch = match[3];
|
||||
const pre = match[4];
|
||||
const buildPart = match[5];
|
||||
const base = `${major}.${minor}.${patch}`;
|
||||
|
||||
if (buildPart !== undefined) {
|
||||
throw new Error(
|
||||
`Version "${trimmed}" has +build metadata — map manually or drop build for WiX`,
|
||||
);
|
||||
}
|
||||
|
||||
if (pre === undefined) {
|
||||
return `${base}.${WIX_BUILD.STABLE}`;
|
||||
}
|
||||
|
||||
if (pre === 'dev') {
|
||||
return `${base}.${WIX_BUILD.DEV}`;
|
||||
}
|
||||
|
||||
const rc = pre.match(/^rc\.(\d+)$/);
|
||||
if (rc) {
|
||||
const n = Number(rc[1]);
|
||||
if (!Number.isInteger(n) || n < 1) {
|
||||
throw new Error(`WiX rc index must be ≥ 1 (got rc.${rc[1]})`);
|
||||
}
|
||||
const build = WIX_BUILD.RC_BASE + n;
|
||||
assertBuildField(build, `rc.${n}`);
|
||||
return `${base}.${build}`;
|
||||
}
|
||||
|
||||
if (/^\d+$/.test(pre)) {
|
||||
const n = Number(pre);
|
||||
const build = WIX_BUILD.RC_BASE + n;
|
||||
assertBuildField(build, `pre ${pre}`);
|
||||
return `${base}.${build}`;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Version "${trimmed}" has non-numeric pre-release "${pre}" — MSI/WiX cannot bundle it. ` +
|
||||
'Use NSIS (`--bundles nsis`) or extend wix-bundle-version.mjs.',
|
||||
);
|
||||
}
|
||||
|
||||
/** Numeric build field from mapped WiX version (for monotonicity tests). */
|
||||
export function wixMappedBuildNumber(packageVersion) {
|
||||
const wix = wixVersionOverrideForPackageVersion(packageVersion);
|
||||
const build = Number(wix.split('.')[3]);
|
||||
assertBuildField(build, packageVersion);
|
||||
return build;
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
WIX_BUILD,
|
||||
wixMappedBuildNumber,
|
||||
wixVersionOverrideForPackageVersion,
|
||||
} from './wix-bundle-version.mjs';
|
||||
|
||||
describe('wixVersionOverrideForPackageVersion', () => {
|
||||
it('maps -dev to lowest build band', () => {
|
||||
assert.equal(wixVersionOverrideForPackageVersion('1.50.0-dev'), '1.50.0.1');
|
||||
});
|
||||
|
||||
it('maps -rc.N to RC base + N', () => {
|
||||
assert.equal(wixVersionOverrideForPackageVersion('1.50.0-rc.3'), '1.50.0.10003');
|
||||
});
|
||||
|
||||
it('maps stable to highest build band', () => {
|
||||
assert.equal(wixVersionOverrideForPackageVersion('1.50.0'), '1.50.0.65534');
|
||||
});
|
||||
|
||||
it('maps numeric pre-release to RC band', () => {
|
||||
assert.equal(wixVersionOverrideForPackageVersion('1.50.0-42'), '1.50.0.10042');
|
||||
});
|
||||
});
|
||||
|
||||
describe('monotonic promotion builds within X.Y.Z', () => {
|
||||
it('dev < rc.1 < rc.2 < stable', () => {
|
||||
const chain = ['1.50.0-dev', '1.50.0-rc.1', '1.50.0-rc.2', '1.50.0'];
|
||||
let prev = -1;
|
||||
for (const v of chain) {
|
||||
const build = wixMappedBuildNumber(v);
|
||||
assert.ok(build > prev, `${v} build ${build} must exceed ${prev}`);
|
||||
prev = build;
|
||||
}
|
||||
assert.equal(prev, WIX_BUILD.STABLE);
|
||||
});
|
||||
});
|
||||
@@ -1,29 +1,13 @@
|
||||
[workspace]
|
||||
members = ["crates/*"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "1.51.0-dev"
|
||||
edition = "2021"
|
||||
rust-version = "1.95"
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
[workspace.dependencies]
|
||||
tempfile = "3"
|
||||
wiremock = "0.6"
|
||||
mockall = "0.13"
|
||||
proptest = "1"
|
||||
|
||||
[package]
|
||||
name = "psysonic"
|
||||
version.workspace = true
|
||||
version = "0.1.2"
|
||||
description = "Psysonic Desktop Music Player"
|
||||
authors = []
|
||||
license.workspace = true
|
||||
license = ""
|
||||
repository = ""
|
||||
default-run = "psysonic"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
edition = "2021"
|
||||
rust-version = "1.77.2"
|
||||
|
||||
[lib]
|
||||
name = "psysonic_lib"
|
||||
@@ -37,79 +21,12 @@ path = "src/main.rs"
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
psysonic-core = { path = "crates/psysonic-core" }
|
||||
psysonic-analysis = { path = "crates/psysonic-analysis" }
|
||||
psysonic-audio = { path = "crates/psysonic-audio" }
|
||||
psysonic-library = { path = "crates/psysonic-library" }
|
||||
psysonic-syncfs = { path = "crates/psysonic-syncfs" }
|
||||
psysonic-integration = { path = "crates/psysonic-integration" }
|
||||
tauri = { version = "2", features = ["protocol-asset", "tray-icon", "image-png"] }
|
||||
specta = "=2.0.0-rc.25"
|
||||
specta-typescript = "=0.0.12"
|
||||
tauri-specta = { version = "=2.0.0-rc.25", features = ["derive", "typescript"] }
|
||||
tauri-plugin-single-instance = "2"
|
||||
tauri = { version = "2", features = ["tray-icon", "image-png"] }
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-notification = "2"
|
||||
tauri-plugin-global-shortcut = "2"
|
||||
tauri-plugin-store = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-fs = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
rodio = { version = "0.22", default-features = false, features = ["playback"] }
|
||||
symphonia = { version = "0.6", default-features = false, features = ["flac", "mp3", "pcm", "aac", "alac", "isomp4", "vorbis", "ogg", "wav", "adpcm", "all-meta"] }
|
||||
reqwest = { version = "0.13", default-features = false, features = ["stream", "json", "multipart", "query", "form", "rustls", "blocking", "gzip", "brotli"] }
|
||||
futures-util = "0.3"
|
||||
md5 = "0.8"
|
||||
tokio = { version = "1", features = ["rt", "time", "sync"] }
|
||||
biquad = "0.6"
|
||||
ringbuf = "0.5"
|
||||
tauri-plugin-window-state = "2.4.1"
|
||||
tauri-plugin-process = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
souvlaki = { version = "0.8", default-features = false, features = ["use_zbus"] }
|
||||
discord-rich-presence = "1.1"
|
||||
url = "2"
|
||||
thread-priority = "3"
|
||||
lofty = "0.24"
|
||||
sysinfo = { version = "0.39", default-features = false, features = ["disk", "system"] }
|
||||
id3 = "1.17"
|
||||
symphonia-adapter-libopus = "0.3"
|
||||
rusqlite = { version = "0.40", features = ["bundled"] }
|
||||
ebur128 = "0.1"
|
||||
dasp_sample = "0.11.0"
|
||||
zip = "8"
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] }
|
||||
webp = "0.3"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
mach2 = "0.5"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
zbus = { version = "5.16", default-features = false, features = ["blocking-api", "async-io"] }
|
||||
# Match wry/tauri’s WebKitGTK stack — used only to turn off kinetic wheel scrolling.
|
||||
webkit2gtk = { version = "2.0", default-features = false, features = ["v2_40"] }
|
||||
webkit2gtk-nvidia-quirk = "1.3"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { version = "0.62", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Graphics",
|
||||
"Win32_Graphics_Gdi",
|
||||
"Win32_System_Com",
|
||||
"Win32_System_Power",
|
||||
"Win32_System_Threading",
|
||||
"Win32_UI_Controls",
|
||||
"Win32_UI_Shell",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
] }
|
||||
|
||||
# NOTE: The local `symphonia-format-isomp4` path patch (0.5-based) was removed for
|
||||
# the Symphonia 0.6 migration. Symphonia 0.6 upstream already covers the esds
|
||||
# missing-descriptor and SL predefined=null fixes. The malformed-trak skip and
|
||||
# moov-at-end tail scan are validated against the fixture corpus on stock 0.6; if a
|
||||
# case regresses, re-create the patch from the 0.6 isomp4 source and re-add the
|
||||
# [patch.crates-io] entry here. See workdocs task 2026-05-symphonia-0.6-migration.
|
||||
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- Sandbox disabled: app is unsigned/ad-hoc signed, so an active sandbox
|
||||
would restrict network access more than without it. macOS users bypass
|
||||
Gatekeeper via: xattr -cr /Applications/Psysonic.app -->
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<false/>
|
||||
|
||||
<!-- Allow outbound TCP connections to the Navidrome server (reqwest in audio.rs).
|
||||
Respected by ad-hoc signatures on some macOS versions. -->
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,14 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- Allow HTTP (non-TLS) connections so that internet radio streams and
|
||||
Navidrome servers running without HTTPS can load media in WKWebView.
|
||||
Without this, macOS App Transport Security blocks HTTP audio src. -->
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,26 +0,0 @@
|
||||
{{!--
|
||||
cargo-about template — emits JSON grouped by license. The Node generator
|
||||
(scripts/generate-licenses.mjs) flattens this into a per-crate list and
|
||||
merges it with the npm license data into the final public/licenses.json.
|
||||
--}}
|
||||
{
|
||||
"licenses": [
|
||||
{{#each licenses}}
|
||||
{
|
||||
"id": "{{id}}",
|
||||
"name": "{{name}}",
|
||||
"text": {{json text}},
|
||||
"used_by": [
|
||||
{{#each used_by}}
|
||||
{
|
||||
"name": "{{crate.name}}",
|
||||
"version": "{{crate.version}}",
|
||||
"repository": {{json crate.repository}},
|
||||
"description": {{json crate.description}}
|
||||
}{{#unless @last}},{{/unless}}
|
||||
{{/each}}
|
||||
]
|
||||
}{{#unless @last}},{{/unless}}
|
||||
{{/each}}
|
||||
]
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
# cargo-about config — controls which licenses are allowed in the dependency
|
||||
# tree and how the licenses listing is generated. Permissive list: anything
|
||||
# the Rust ecosystem commonly publishes under, plus a few exceptions.
|
||||
#
|
||||
# Run via `npm run licenses:generate` (which calls `cargo about generate`).
|
||||
|
||||
accepted = [
|
||||
"MIT",
|
||||
"Apache-2.0",
|
||||
"Apache-2.0 WITH LLVM-exception",
|
||||
"BSD-2-Clause",
|
||||
"BSD-3-Clause",
|
||||
"ISC",
|
||||
"MPL-2.0",
|
||||
"Unicode-DFS-2016",
|
||||
"Unicode-3.0",
|
||||
"Zlib",
|
||||
"0BSD",
|
||||
"CC0-1.0",
|
||||
"Unlicense",
|
||||
"OpenSSL",
|
||||
"BSL-1.0",
|
||||
"CDLA-Permissive-2.0",
|
||||
"GPL-3.0-or-later",
|
||||
"bzip2-1.0.6",
|
||||
]
|
||||
|
||||
# Skip the build host's own platform-pinning; we want a list across all targets
|
||||
# we ship binaries for so the Linux user sees Linux-only deps too, etc.
|
||||
targets = [
|
||||
"x86_64-unknown-linux-gnu",
|
||||
"aarch64-unknown-linux-gnu",
|
||||
"x86_64-pc-windows-msvc",
|
||||
"x86_64-apple-darwin",
|
||||
"aarch64-apple-darwin",
|
||||
]
|
||||
|
||||
# Show transitive deps (we want everything in the binary) but skip dev-deps
|
||||
# (test-only crates aren't in the shipped artifact).
|
||||
ignore-build-dependencies = false
|
||||
ignore-dev-dependencies = true
|
||||
ignore-transitive-dependencies = false
|
||||
workarounds = ["ring"]
|
||||
@@ -1,20 +1,3 @@
|
||||
fn main() {
|
||||
// Windows/MSVC test binaries only: bind to Common-Controls v6.
|
||||
//
|
||||
// The library test harness (`--lib` unittests) links the wry/tao windowing
|
||||
// runtime and statically imports `TaskDialogIndirect` from comctl32, which
|
||||
// exists only in Common-Controls v6 (WinSxS). The real app binary gets that
|
||||
// manifest from `tauri_build`, but the separate test executable does not, so
|
||||
// it aborts at startup with STATUS_ENTRYPOINT_NOT_FOUND (0xC0000139). Add the
|
||||
// dependency to test targets only — never the app binary (which already has a
|
||||
// manifest via tauri_build) nor non-Windows/CI builds.
|
||||
let is_windows_msvc = std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows")
|
||||
&& std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc");
|
||||
if is_windows_msvc {
|
||||
println!(
|
||||
"cargo::rustc-link-arg=/MANIFESTDEPENDENCY:type='win32' name='Microsoft.Windows.Common-Controls' version='6.0.0.0' processorArchitecture='*' publicKeyToken='6595b64144ccf1df' language='*'"
|
||||
);
|
||||
}
|
||||
|
||||
tauri_build::build()
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/capability-schema.json",
|
||||
"$schema": "https://schema.tauri.app/config/2/capability.json",
|
||||
"identifier": "default",
|
||||
"description": "Default capabilities for Psysonic",
|
||||
"platforms": ["linux", "macOS", "windows"],
|
||||
"windows": ["main", "mini"],
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"shell:default",
|
||||
{ "identifier": "shell:allow-open", "allow": [{ "url": "https://**" }] },
|
||||
"shell:allow-open",
|
||||
"notification:default",
|
||||
"global-shortcut:allow-register",
|
||||
"global-shortcut:allow-unregister",
|
||||
"store:default",
|
||||
@@ -17,30 +18,14 @@
|
||||
"store:allow-save",
|
||||
"dialog:default",
|
||||
"dialog:allow-open",
|
||||
"dialog:allow-save",
|
||||
"fs:default",
|
||||
"fs:allow-write-file",
|
||||
"fs:allow-read-file",
|
||||
"fs:allow-mkdir",
|
||||
"fs:allow-app-write-recursive",
|
||||
"fs:scope-download-recursive",
|
||||
"fs:scope-home-recursive",
|
||||
"window-state:allow-save-window-state",
|
||||
"window-state:allow-restore-state",
|
||||
"core:window:allow-set-title",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-hide",
|
||||
"core:window:allow-show",
|
||||
"core:window:allow-set-fullscreen",
|
||||
"core:window:allow-is-fullscreen",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-create",
|
||||
"core:window:allow-set-size",
|
||||
"core:webview:allow-create-webview-window",
|
||||
"core:webview:allow-set-webview-zoom",
|
||||
"process:allow-restart",
|
||||
"updater:default"
|
||||
"core:window:allow-show"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
[package]
|
||||
name = "psysonic-analysis"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
psysonic-core = { path = "../psysonic-core" }
|
||||
|
||||
tauri = { version = "2" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
specta = { version = "=2.0.0-rc.25", features = ["derive"] }
|
||||
tokio = { version = "1", features = ["rt", "time", "sync"] }
|
||||
reqwest = { version = "0.13", default-features = false, features = ["stream", "rustls", "gzip", "brotli"] }
|
||||
futures-util = "0.3"
|
||||
ebur128 = "0.1"
|
||||
md5 = "0.8"
|
||||
rusqlite = { version = "0.40", features = ["bundled"] }
|
||||
symphonia = { version = "0.6", default-features = false, features = ["flac", "mp3", "pcm", "aac", "alac", "isomp4", "vorbis", "ogg", "wav", "adpcm", "all-meta"] }
|
||||
symphonia-adapter-libopus = "0.3"
|
||||
oximedia-mir = { version = "0.1.7", default-features = false, features = ["tempo", "mood"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tauri = { version = "2", features = ["test"] }
|
||||
@@ -1,19 +0,0 @@
|
||||
fn main() {
|
||||
// Windows/MSVC test binaries only: bind to Common-Controls v6.
|
||||
//
|
||||
// The `tauri` dev-dependency pulls the wry/tao windowing runtime into this
|
||||
// crate's *test* executables, which statically import `TaskDialogIndirect`
|
||||
// from comctl32. That symbol lives only in Common-Controls v6 (WinSxS); the
|
||||
// bare System32 comctl32.dll is v5.82 and does not export it, so an
|
||||
// unmanifested test exe aborts at startup with STATUS_ENTRYPOINT_NOT_FOUND
|
||||
// (0xC0000139) before any test runs. The app binary avoids this through its
|
||||
// embedded manifest — mirror that here, scoped to test targets only (never
|
||||
// the app, the rlib, or non-Windows/CI builds).
|
||||
let is_windows_msvc = std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows")
|
||||
&& std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc");
|
||||
if is_windows_msvc {
|
||||
println!(
|
||||
"cargo::rustc-link-arg=/MANIFESTDEPENDENCY:type='win32' name='Microsoft.Windows.Common-Controls' version='6.0.0.0' processorArchitecture='*' publicKeyToken='6595b64144ccf1df' language='*'"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
-- Baseline: the pre-versioning analysis cache schema.
|
||||
--
|
||||
-- This is the exact shape every existing user DB already carries (created by
|
||||
-- the old `CREATE TABLE IF NOT EXISTS` bootstrap). `IF NOT EXISTS` keeps it a
|
||||
-- no-op on those DBs and creates the tables on a fresh one, so "migration 1
|
||||
-- applied" means "the schema that shipped before versioned migrations".
|
||||
--
|
||||
-- Server-scoping (server_id) is added additively in 002.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS analysis_track (
|
||||
track_id TEXT NOT NULL,
|
||||
md5_16kb TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
waveform_algo_version INTEGER NOT NULL,
|
||||
loudness_algo_version INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (track_id, md5_16kb)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS waveform_cache (
|
||||
track_id TEXT NOT NULL,
|
||||
md5_16kb TEXT NOT NULL,
|
||||
bins BLOB NOT NULL,
|
||||
bin_count INTEGER NOT NULL,
|
||||
is_partial INTEGER NOT NULL,
|
||||
known_until_sec REAL NOT NULL,
|
||||
duration_sec REAL NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (track_id, md5_16kb)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS loudness_cache (
|
||||
track_id TEXT NOT NULL,
|
||||
md5_16kb TEXT NOT NULL,
|
||||
integrated_lufs REAL NOT NULL,
|
||||
true_peak REAL NOT NULL,
|
||||
recommended_gain_db REAL NOT NULL,
|
||||
target_lufs REAL NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (track_id, md5_16kb, target_lufs)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_analysis_track_status
|
||||
ON analysis_track(status);
|
||||
@@ -1,73 +0,0 @@
|
||||
-- Add `server_id` to the analysis cache so waveform/loudness rows are scoped
|
||||
-- per server (E1 / R7-16). SQLite cannot change a PRIMARY KEY in place, so each
|
||||
-- table is rebuilt: create the v2 shape, copy every row with server_id = '',
|
||||
-- drop the old table, rename. Existing rows become legacy ('') rows that the
|
||||
-- read path still finds (server -> legacy -> lazy re-tag, added in 6c-2).
|
||||
--
|
||||
-- Atomicity: the migration runner wraps this whole file plus the
|
||||
-- schema_migrations marker in one transaction, so any failure or crash rolls
|
||||
-- everything back to the original tables — DROP never runs unless the copy
|
||||
-- before it succeeded. No BEGIN/COMMIT here (that would nest).
|
||||
--
|
||||
-- These three tables have no foreign keys between them or from any other table,
|
||||
-- so the drop/rename needs no `PRAGMA foreign_keys` toggle (which is a no-op
|
||||
-- inside a transaction anyway).
|
||||
|
||||
-- analysis_track
|
||||
CREATE TABLE analysis_track_v2 (
|
||||
server_id TEXT NOT NULL DEFAULT '',
|
||||
track_id TEXT NOT NULL,
|
||||
md5_16kb TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
waveform_algo_version INTEGER NOT NULL,
|
||||
loudness_algo_version INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (server_id, track_id, md5_16kb)
|
||||
);
|
||||
INSERT INTO analysis_track_v2
|
||||
(server_id, track_id, md5_16kb, status, waveform_algo_version, loudness_algo_version, updated_at)
|
||||
SELECT '', track_id, md5_16kb, status, waveform_algo_version, loudness_algo_version, updated_at
|
||||
FROM analysis_track;
|
||||
DROP TABLE analysis_track;
|
||||
ALTER TABLE analysis_track_v2 RENAME TO analysis_track;
|
||||
CREATE INDEX IF NOT EXISTS idx_analysis_track_status
|
||||
ON analysis_track(status);
|
||||
|
||||
-- waveform_cache
|
||||
CREATE TABLE waveform_cache_v2 (
|
||||
server_id TEXT NOT NULL DEFAULT '',
|
||||
track_id TEXT NOT NULL,
|
||||
md5_16kb TEXT NOT NULL,
|
||||
bins BLOB NOT NULL,
|
||||
bin_count INTEGER NOT NULL,
|
||||
is_partial INTEGER NOT NULL,
|
||||
known_until_sec REAL NOT NULL,
|
||||
duration_sec REAL NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (server_id, track_id, md5_16kb)
|
||||
);
|
||||
INSERT INTO waveform_cache_v2
|
||||
(server_id, track_id, md5_16kb, bins, bin_count, is_partial, known_until_sec, duration_sec, updated_at)
|
||||
SELECT '', track_id, md5_16kb, bins, bin_count, is_partial, known_until_sec, duration_sec, updated_at
|
||||
FROM waveform_cache;
|
||||
DROP TABLE waveform_cache;
|
||||
ALTER TABLE waveform_cache_v2 RENAME TO waveform_cache;
|
||||
|
||||
-- loudness_cache
|
||||
CREATE TABLE loudness_cache_v2 (
|
||||
server_id TEXT NOT NULL DEFAULT '',
|
||||
track_id TEXT NOT NULL,
|
||||
md5_16kb TEXT NOT NULL,
|
||||
integrated_lufs REAL NOT NULL,
|
||||
true_peak REAL NOT NULL,
|
||||
recommended_gain_db REAL NOT NULL,
|
||||
target_lufs REAL NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (server_id, track_id, md5_16kb, target_lufs)
|
||||
);
|
||||
INSERT INTO loudness_cache_v2
|
||||
(server_id, track_id, md5_16kb, integrated_lufs, true_peak, recommended_gain_db, target_lufs, updated_at)
|
||||
SELECT '', track_id, md5_16kb, integrated_lufs, true_peak, recommended_gain_db, target_lufs, updated_at
|
||||
FROM loudness_cache;
|
||||
DROP TABLE loudness_cache;
|
||||
ALTER TABLE loudness_cache_v2 RENAME TO loudness_cache;
|
||||