feat(desktop): wrap the web client in a Tauri app
ci / api / api (push) Successful in 43s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m19s
ci / desktop / rust (desktop proxy) (push) Failing after 3m34s
ci / ts / ts (packages + web) (push) Failing after 2m49s
release / release (версия и заметки) (push) Successful in 5s
ci / db / db (push) Successful in 40s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m12s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m28s
release / android (APK в релиз) (push) Failing after 2m0s
ci / api / api (push) Successful in 43s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m19s
ci / desktop / rust (desktop proxy) (push) Failing after 3m34s
ci / ts / ts (packages + web) (push) Failing after 2m49s
release / release (версия и заметки) (push) Successful in 5s
ci / db / db (push) Successful in 40s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m12s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m28s
release / android (APK в релиз) (push) Failing after 2m0s
EOF Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
fa79394869
commit
efe78d7e07
33 files changed
+1055
-19
No files matched your search
@@ -0,0 +1,33 @@
|
|||||||
|
# Rust-часть десктопа: прокси и проверки URL. Полная сборка инсталлятора — локально
|
||||||
|
# (`pnpm --filter @pipu/desktop bundle`): на раннере нет Windows и WebKitGTK.
|
||||||
|
name: ci / desktop
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'apps/desktop/**'
|
||||||
|
- '.gitea/workflows/ci-desktop.yml'
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'apps/desktop/**'
|
||||||
|
- '.gitea/workflows/ci-desktop.yml'
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
rust:
|
||||||
|
name: rust (desktop proxy)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Rust
|
||||||
|
run: |
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
|
||||||
|
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||||
|
- run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libdbus-1-dev
|
||||||
|
- run: cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# pipu-music
|
# pipu-music
|
||||||
|
|
||||||
Self-hosted музыкальный сервис: веб и Android-приложение, библиотека на NFS,
|
Self-hosted музыкальный сервис: веб, десктоп и Android-приложение, библиотека на NFS,
|
||||||
умная бесконечная лента «Моя волна» с учётом вкуса, звучания треков и настроения.
|
умная бесконечная лента «Моя волна» с учётом вкуса, звучания треков и настроения.
|
||||||
|
|
||||||
- Идея продукта: [docs/IDEA.md](docs/IDEA.md)
|
- Идея продукта: [docs/IDEA.md](docs/IDEA.md)
|
||||||
@@ -15,6 +15,7 @@ Self-hosted музыкальный сервис: веб и Android-прилож
|
|||||||
| `services/api` | Go: публичный API (`cmd/api`) и агент хранилищ (`cmd/storage-agent`) |
|
| `services/api` | Go: публичный API (`cmd/api`) и агент хранилищ (`cmd/storage-agent`) |
|
||||||
| `services/recommender` | Python: рекомендации (`pipu-rec serve`) и аудиоанализ (`pipu-rec analyze`) |
|
| `services/recommender` | Python: рекомендации (`pipu-rec serve`) и аудиоанализ (`pipu-rec analyze`) |
|
||||||
| `apps/web` | Веб-клиент (React + Vite) |
|
| `apps/web` | Веб-клиент (React + Vite) |
|
||||||
|
| `apps/desktop` | Десктоп Windows / Linux / macOS (Tauri, тот же веб) |
|
||||||
| `apps/mobile` | Android-приложение (React Native + Expo) |
|
| `apps/mobile` | Android-приложение (React Native + Expo) |
|
||||||
| `packages/*` | Общий TS-код: `api-client`, `core`, `i18n`, `design-tokens` |
|
| `packages/*` | Общий TS-код: `api-client`, `core`, `i18n`, `design-tokens` |
|
||||||
| `deploy/` | Docker Compose, nginx, скрипты установки и обновления |
|
| `deploy/` | Docker Compose, nginx, скрипты установки и обновления |
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { dirname, resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const root = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const conf = JSON.parse(readFileSync(resolve(root, 'src-tauri/tauri.conf.json'), 'utf8'));
|
||||||
|
let version = '0.0.0';
|
||||||
|
try {
|
||||||
|
const tag = execFileSync('git', ['describe', '--tags', '--abbrev=0'], { encoding: 'utf8' }).trim();
|
||||||
|
version = tag.replace(/^v/, '').replace(/-rc\.\d+$/, '');
|
||||||
|
} catch {
|
||||||
|
// local untagged build
|
||||||
|
}
|
||||||
|
conf.version = version;
|
||||||
|
writeFileSync(resolve(root, 'src-tauri/tauri.release.json'), JSON.stringify(conf, null, 2));
|
||||||
|
console.log(`desktop version ${version}`);
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "@pipu/desktop",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tauri dev",
|
||||||
|
"bundle": "node inject-version.mjs && tauri build --config src-tauri/tauri.release.json",
|
||||||
|
"tauri": "tauri",
|
||||||
|
"icons": "tauri icon ../web/public/favicon.svg"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tauri-apps/cli": "^2.9.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
[package]
|
||||||
|
name = "pipu-desktop"
|
||||||
|
version = "0.0.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "pipu_desktop_lib"
|
||||||
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "pipu-desktop"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tauri = { version = "2", features = ["tray-icon"] }
|
||||||
|
tauri-plugin-window-state = "2"
|
||||||
|
tauri-plugin-single-instance = "2"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
tokio = { version = "1", features = ["net", "sync", "time"] }
|
||||||
|
axum = "0.8"
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] }
|
||||||
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
url = "2"
|
||||||
|
souvlaki = "0.8.3"
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "windows")'.dependencies]
|
||||||
|
keyring = { version = "3.6.3", features = ["windows-native"] }
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
|
keyring = { version = "3.6.3", features = ["sync-secret-service", "crypto-rust"] }
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
|
keyring = { version = "3.6.3", features = ["apple-native"] }
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
strip = true
|
||||||
|
lto = "thin"
|
||||||
|
codegen-units = 1
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "main",
|
||||||
|
"description": "Only the bundled main window may invoke desktop integration.",
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": ["core:default", "core:event:default"]
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 6.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
@@ -0,0 +1,157 @@
|
|||||||
|
mod media;
|
||||||
|
mod proxy;
|
||||||
|
mod store;
|
||||||
|
|
||||||
|
use media::Media;
|
||||||
|
use proxy::Proxy;
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use tauri::{
|
||||||
|
image::Image,
|
||||||
|
include_image,
|
||||||
|
menu::{Menu, MenuItem},
|
||||||
|
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||||
|
AppHandle, Manager, State,
|
||||||
|
};
|
||||||
|
|
||||||
|
struct AppState {
|
||||||
|
proxy: Proxy,
|
||||||
|
media: Media,
|
||||||
|
server: Mutex<Option<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct Bootstrap {
|
||||||
|
proxy_base: String,
|
||||||
|
server: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn show_main(app: &AppHandle) {
|
||||||
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
let _ = window.unminimize();
|
||||||
|
let _ = window.show();
|
||||||
|
let _ = window.set_focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn bootstrap(app: AppHandle, state: State<AppState>) -> Result<Bootstrap, String> {
|
||||||
|
Ok(Bootstrap {
|
||||||
|
proxy_base: state.proxy.base.clone(),
|
||||||
|
server: store::load_server(&app)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn set_server(app: AppHandle, state: State<'_, AppState>, url: String) -> Result<String, String> {
|
||||||
|
let server = proxy::validate_server(&url)?;
|
||||||
|
state.proxy.check(&server).await?;
|
||||||
|
store::save_server(&app, Some(&server))?;
|
||||||
|
*state.proxy.server.write().map_err(|e| e.to_string())? = Some(server.clone());
|
||||||
|
*state.server.lock().map_err(|e| e.to_string())? = Some(server.clone());
|
||||||
|
Ok(server)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn clear_server(app: AppHandle, state: State<AppState>) -> Result<(), String> {
|
||||||
|
store::save_server(&app, None)?;
|
||||||
|
store::save_user(&app, None)?;
|
||||||
|
store::save_refresh(None)?;
|
||||||
|
*state.proxy.server.write().map_err(|e| e.to_string())? = None;
|
||||||
|
*state.server.lock().map_err(|e| e.to_string())? = None;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn token_get() -> Result<Option<String>, String> {
|
||||||
|
store::load_refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn token_set(token: String) -> Result<(), String> {
|
||||||
|
store::save_refresh(Some(&token))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn token_clear() -> Result<(), String> {
|
||||||
|
store::save_refresh(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn user_get(app: AppHandle) -> Result<Option<String>, String> {
|
||||||
|
store::load_user(&app)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn user_set(app: AppHandle, json: String) -> Result<(), String> {
|
||||||
|
store::save_user(&app, Some(&json))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn user_clear(app: AppHandle) -> Result<(), String> {
|
||||||
|
store::save_user(&app, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn media_set(
|
||||||
|
state: State<AppState>,
|
||||||
|
title: String,
|
||||||
|
artist: String,
|
||||||
|
album: String,
|
||||||
|
cover_url: Option<String>,
|
||||||
|
playing: bool,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
state.media.set(&title, &artist, &album, cover_url.as_deref(), playing)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
|
pub fn run() {
|
||||||
|
tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_single_instance::init(|app, _, _| show_main(app)))
|
||||||
|
.plugin(tauri_plugin_window_state::Builder::default().build())
|
||||||
|
.setup(|app| {
|
||||||
|
let saved = store::load_server(app.handle())?;
|
||||||
|
let proxy = tauri::async_runtime::block_on(Proxy::start(saved.clone())).map_err(|e| e.to_string())?;
|
||||||
|
let media = Media::new();
|
||||||
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
let _ = media.attach(app.handle().clone(), &window);
|
||||||
|
}
|
||||||
|
app.manage(AppState {
|
||||||
|
proxy,
|
||||||
|
media,
|
||||||
|
server: Mutex::new(saved),
|
||||||
|
});
|
||||||
|
|
||||||
|
let open = MenuItem::with_id(app, "open", "Открыть", true, None::<&str>)?;
|
||||||
|
let quit = MenuItem::with_id(app, "quit", "Выход", true, None::<&str>)?;
|
||||||
|
let menu = Menu::with_items(app, &[&open, &quit])?;
|
||||||
|
let icon: Image = include_image!("../icons/32x32.png");
|
||||||
|
TrayIconBuilder::new()
|
||||||
|
.icon(icon)
|
||||||
|
.menu(&menu)
|
||||||
|
.show_menu_on_left_click(false)
|
||||||
|
.on_menu_event(|app, event| match event.id.as_ref() {
|
||||||
|
"open" => show_main(app),
|
||||||
|
"quit" => app.exit(0),
|
||||||
|
_ => {}
|
||||||
|
})
|
||||||
|
.on_tray_icon_event(|tray, event| {
|
||||||
|
if let TrayIconEvent::Click {
|
||||||
|
button: MouseButton::Left,
|
||||||
|
button_state: MouseButtonState::Up,
|
||||||
|
..
|
||||||
|
} = event
|
||||||
|
{
|
||||||
|
show_main(tray.app_handle());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.build(app)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
bootstrap, set_server, clear_server, token_get, token_set, token_clear, user_get, user_set, user_clear, media_set
|
||||||
|
])
|
||||||
|
.run(tauri::generate_context!())
|
||||||
|
.expect("desktop app failed to start");
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
pipu_desktop_lib::run();
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
use souvlaki::{MediaControlEvent, MediaControls, MediaMetadata, MediaPlayback, PlatformConfig};
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use tauri::{AppHandle, Emitter, WebviewWindow};
|
||||||
|
|
||||||
|
pub struct Media {
|
||||||
|
controls: Mutex<Option<MediaControls>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Media {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self { controls: Mutex::new(None) }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn attach(&self, app: AppHandle, window: &WebviewWindow) -> Result<(), String> {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
let hwnd = window.hwnd().ok().map(|h| h.0 as *mut std::ffi::c_void);
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
let hwnd = {
|
||||||
|
let _ = window;
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let mut controls = MediaControls::new(PlatformConfig {
|
||||||
|
dbus_name: "app.pipu.music.desktop",
|
||||||
|
display_name: "Pipu Music",
|
||||||
|
hwnd,
|
||||||
|
})
|
||||||
|
.map_err(|e| format!("{e:?}"))?;
|
||||||
|
let handle = app.clone();
|
||||||
|
controls
|
||||||
|
.attach(move |event| {
|
||||||
|
let name = match event {
|
||||||
|
MediaControlEvent::Play => "play",
|
||||||
|
MediaControlEvent::Pause => "pause",
|
||||||
|
MediaControlEvent::Toggle => "toggle",
|
||||||
|
MediaControlEvent::Next => "next",
|
||||||
|
MediaControlEvent::Previous => "previous",
|
||||||
|
MediaControlEvent::Stop => "stop",
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
let _ = handle.emit("media", name);
|
||||||
|
})
|
||||||
|
.map_err(|e| format!("{e:?}"))?;
|
||||||
|
*self.controls.lock().map_err(|e| e.to_string())? = Some(controls);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set(&self, title: &str, artist: &str, album: &str, cover_url: Option<&str>, playing: bool) -> Result<(), String> {
|
||||||
|
let mut slot = self.controls.lock().map_err(|e| e.to_string())?;
|
||||||
|
let Some(controls) = slot.as_mut() else { return Ok(()); };
|
||||||
|
controls
|
||||||
|
.set_metadata(MediaMetadata {
|
||||||
|
title: Some(title),
|
||||||
|
artist: Some(artist),
|
||||||
|
album: Some(album),
|
||||||
|
cover_url,
|
||||||
|
duration: None,
|
||||||
|
})
|
||||||
|
.map_err(|e| format!("{e:?}"))?;
|
||||||
|
let playback = if playing { MediaPlayback::Playing { progress: None } } else { MediaPlayback::Paused { progress: None } };
|
||||||
|
controls.set_playback(playback).map_err(|e| format!("{e:?}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
//! Loopback-only streaming transport. The random per-process path is a capability;
|
||||||
|
//! it never leaves the app except for OS media artwork requests. No open proxy.
|
||||||
|
use axum::{body::Body, extract::{Request, State}, http::{HeaderMap, HeaderValue, Method, StatusCode}, response::{IntoResponse, Response}, Router};
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::{sync::{Arc, RwLock}, time::Duration};
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Proxy {
|
||||||
|
pub server: Arc<RwLock<Option<String>>>,
|
||||||
|
pub base: String,
|
||||||
|
secret: String,
|
||||||
|
authority: String,
|
||||||
|
client: reqwest::Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_server(raw: &str) -> Result<String, String> {
|
||||||
|
let url = Url::parse(raw.trim()).map_err(|_| "Invalid server URL")?;
|
||||||
|
if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none()
|
||||||
|
|| !url.username().is_empty() || url.password().is_some()
|
||||||
|
|| url.query().is_some() || url.fragment().is_some()
|
||||||
|
{ return Err("Use an HTTP(S) server address without credentials, query or fragment".into()); }
|
||||||
|
Ok(url.as_str().trim_end_matches('/').to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Proxy {
|
||||||
|
pub async fn start(server: Option<String>) -> Result<Self, String> {
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.map_err(|e| e.to_string())?;
|
||||||
|
let authority = listener.local_addr().map_err(|e| e.to_string())?.to_string();
|
||||||
|
let secret = uuid::Uuid::new_v4().simple().to_string();
|
||||||
|
let proxy = Self {
|
||||||
|
server: Arc::new(RwLock::new(server)),
|
||||||
|
base: format!("http://{authority}/{secret}"), secret, authority,
|
||||||
|
client: reqwest::Client::builder()
|
||||||
|
.redirect(reqwest::redirect::Policy::none())
|
||||||
|
.connect_timeout(Duration::from_secs(10))
|
||||||
|
.read_timeout(Duration::from_secs(60))
|
||||||
|
.build().map_err(|e| e.to_string())?,
|
||||||
|
};
|
||||||
|
let router = Router::new().fallback(forward).with_state(proxy.clone());
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
if let Err(e) = axum::serve(listener, router).await { eprintln!("Desktop transport stopped: {e}"); }
|
||||||
|
});
|
||||||
|
Ok(proxy)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn check(&self, server: &str) -> Result<(), String> {
|
||||||
|
let response = self.client.get(format!("{server}/api/v1/server-info"))
|
||||||
|
.timeout(Duration::from_secs(10)).send().await.map_err(|_| "Server unavailable")?;
|
||||||
|
if !response.status().is_success() { return Err("Server unavailable".into()); }
|
||||||
|
let info: Value = response.json().await.map_err(|_| "Not a Pipu server")?;
|
||||||
|
if info.get("api_version").is_none() || info.get("name").is_none() { return Err("Not a Pipu server".into()); }
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn allowed_origin(origin: &str) -> bool {
|
||||||
|
matches!(origin, "tauri://localhost" | "http://tauri.localhost")
|
||||||
|
|| (cfg!(debug_assertions) && origin == "http://127.0.0.1:5173")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signed media and tus URLs are relative in the API. Rewrite only API URLs from
|
||||||
|
// the configured server; never route arbitrary URLs supplied in server content.
|
||||||
|
fn local_url(value: &str, server: &str, base: &str) -> Option<String> {
|
||||||
|
if value.starts_with("/api/") { return Some(format!("{base}{value}")); }
|
||||||
|
value.strip_prefix(server).filter(|p| p.starts_with("/api/"))
|
||||||
|
.map(|p| format!("{base}{p}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rewrite_json(value: &mut Value, server: &str, base: &str) {
|
||||||
|
match value {
|
||||||
|
Value::Array(items) => items.iter_mut().for_each(|v| rewrite_json(v, server, base)),
|
||||||
|
Value::Object(fields) => for (key, v) in fields {
|
||||||
|
if matches!(key.as_str(), "stream_url" | "cover_url" | "tus_url" | "download_url") {
|
||||||
|
if let Some(url) = v.as_str().and_then(|s| local_url(s, server, base)) { *v = Value::String(url); }
|
||||||
|
} else { rewrite_json(v, server, base); }
|
||||||
|
},
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cors(headers: &mut HeaderMap, origin: Option<&str>) {
|
||||||
|
if let Some(origin) = origin {
|
||||||
|
headers.insert("access-control-allow-origin", HeaderValue::from_str(origin).unwrap());
|
||||||
|
headers.insert("vary", HeaderValue::from_static("Origin"));
|
||||||
|
}
|
||||||
|
headers.insert("access-control-allow-methods", HeaderValue::from_static("GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS"));
|
||||||
|
headers.insert("access-control-allow-headers", HeaderValue::from_static("authorization, content-type, range, tus-resumable, upload-length, upload-offset, upload-metadata, upload-defer-length, x-requested-with"));
|
||||||
|
headers.insert("access-control-expose-headers", HeaderValue::from_static("content-range, accept-ranges, location, upload-offset, upload-length, tus-resumable, retry-after"));
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn forward(State(proxy): State<Proxy>, request: Request) -> Response {
|
||||||
|
let origin = request.headers().get("origin").and_then(|v| v.to_str().ok()).map(str::to_owned);
|
||||||
|
if request.headers().get("host").and_then(|v| v.to_str().ok()) != Some(&proxy.authority)
|
||||||
|
|| origin.as_deref().is_some_and(|s| !allowed_origin(s))
|
||||||
|
{ return StatusCode::FORBIDDEN.into_response(); }
|
||||||
|
let prefix = format!("/{}/api/", proxy.secret);
|
||||||
|
let Some(tail) = request.uri().path().strip_prefix(&prefix) else { return StatusCode::NOT_FOUND.into_response(); };
|
||||||
|
// Prevent URL normalization escaping the configured API path, including encoded segments.
|
||||||
|
if tail.split('/').any(|s| s == "." || s == "..") || tail.to_lowercase().contains("%2e")
|
||||||
|
|| tail.contains('\\') || tail.to_lowercase().contains("%5c") || tail.to_lowercase().contains("%2f")
|
||||||
|
{ return StatusCode::BAD_REQUEST.into_response(); }
|
||||||
|
if origin.is_none() && !matches!(*request.method(), Method::GET | Method::HEAD) {
|
||||||
|
return StatusCode::FORBIDDEN.into_response();
|
||||||
|
}
|
||||||
|
let mut response = if request.method() == Method::OPTIONS {
|
||||||
|
StatusCode::NO_CONTENT.into_response()
|
||||||
|
} else {
|
||||||
|
match transfer(&proxy, request, tail.to_owned()).await {
|
||||||
|
Ok(response) => response,
|
||||||
|
Err(_) => (StatusCode::BAD_GATEWAY, "Server unavailable").into_response(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
cors(response.headers_mut(), origin.as_deref());
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn transfer(proxy: &Proxy, request: Request, tail: String) -> Result<Response, String> {
|
||||||
|
let server = proxy.server.read().map_err(|_| "State unavailable")?.clone().ok_or("No server selected")?;
|
||||||
|
let query = request.uri().query().map(|q| format!("?{q}")).unwrap_or_default();
|
||||||
|
let url = format!("{server}/api/{tail}{query}");
|
||||||
|
let (parts, body) = request.into_parts();
|
||||||
|
let mut upstream = proxy.client.request(parts.method.clone(), url);
|
||||||
|
for name in ["authorization", "content-type", "content-length", "range", "if-range", "if-none-match", "tus-resumable", "upload-length", "upload-offset", "upload-metadata", "upload-defer-length"] {
|
||||||
|
if let Some(value) = parts.headers.get(name) { upstream = upstream.header(name, value); }
|
||||||
|
}
|
||||||
|
let upstream = upstream.body(reqwest::Body::wrap_stream(body.into_data_stream())).send().await.map_err(|e| e.to_string())?;
|
||||||
|
let status = upstream.status();
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
for name in ["content-type", "content-length", "content-range", "accept-ranges", "etag", "last-modified", "cache-control", "upload-offset", "upload-length", "tus-resumable", "retry-after", "content-disposition"] {
|
||||||
|
if let Some(value) = upstream.headers().get(name) { headers.insert(name, value.clone()); }
|
||||||
|
}
|
||||||
|
if let Some(location) = upstream.headers().get("location").and_then(|v| v.to_str().ok()) {
|
||||||
|
let local = local_url(location, &server, &proxy.base).ok_or("External redirect refused")?;
|
||||||
|
headers.insert("location", HeaderValue::from_str(&local).map_err(|e| e.to_string())?);
|
||||||
|
}
|
||||||
|
let json = headers.get("content-type").and_then(|v| v.to_str().ok())
|
||||||
|
.is_some_and(|v| v.contains("application/json") || v.contains("application/problem+json"));
|
||||||
|
let body = if json && parts.method != Method::HEAD {
|
||||||
|
// Bound only JSON, never buffer a song or an upload in memory.
|
||||||
|
let bytes = axum::body::to_bytes(Body::from_stream(upstream.bytes_stream()), 16 * 1024 * 1024).await.map_err(|e| e.to_string())?;
|
||||||
|
let mut value: Value = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?;
|
||||||
|
rewrite_json(&mut value, &server, &proxy.base);
|
||||||
|
headers.remove("content-length");
|
||||||
|
headers.remove("etag");
|
||||||
|
Body::from(serde_json::to_vec(&value).map_err(|e| e.to_string())?)
|
||||||
|
} else { Body::from_stream(upstream.bytes_stream()) };
|
||||||
|
let mut response = Response::new(body);
|
||||||
|
*response.status_mut() = status;
|
||||||
|
*response.headers_mut() = headers;
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn validates_server_and_scopes_media_urls() {
|
||||||
|
assert_eq!(validate_server("https://music.example/pipu/").unwrap(), "https://music.example/pipu");
|
||||||
|
for url in ["file:///etc/passwd", "https://user:pass@host", "https://host/?token=x", "https://host/#x"] { assert!(validate_server(url).is_err()); }
|
||||||
|
assert_eq!(local_url("https://music.example.evil/api/a", "https://music.example", "http://local"), None);
|
||||||
|
let mut value = serde_json::json!({"tracks": [{"stream_url": "/api/v1/stream/a?sig=b", "title": "/api/private", "album": {"cover_url": "/api/v1/covers/a"}}]});
|
||||||
|
rewrite_json(&mut value, "https://music.example", "http://local/key");
|
||||||
|
assert_eq!(value["tracks"][0]["stream_url"], "http://local/key/api/v1/stream/a?sig=b");
|
||||||
|
assert_eq!(value["tracks"][0]["title"], "/api/private");
|
||||||
|
assert_eq!(value["tracks"][0]["album"]["cover_url"], "http://local/key/api/v1/covers/a");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use tauri::{AppHandle, Manager};
|
||||||
|
|
||||||
|
const SERVICE: &str = "app.pipu.music.desktop";
|
||||||
|
|
||||||
|
fn config_dir(app: &AppHandle) -> Result<PathBuf, String> {
|
||||||
|
let dir = app.path().app_config_dir().map_err(|e| e.to_string())?;
|
||||||
|
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||||
|
Ok(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_server(app: &AppHandle) -> Result<Option<String>, String> {
|
||||||
|
let path = config_dir(app)?.join("server.txt");
|
||||||
|
match fs::read_to_string(path) {
|
||||||
|
Ok(s) => {
|
||||||
|
let s = s.trim().to_string();
|
||||||
|
Ok((!s.is_empty()).then_some(s))
|
||||||
|
}
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||||
|
Err(e) => Err(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_server(app: &AppHandle, server: Option<&str>) -> Result<(), String> {
|
||||||
|
let path = config_dir(app)?.join("server.txt");
|
||||||
|
match server {
|
||||||
|
Some(s) => fs::write(path, s).map_err(|e| e.to_string()),
|
||||||
|
None => match fs::remove_file(path) {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||||
|
Err(e) => Err(e.to_string()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refresh_entry() -> Result<keyring::Entry, String> {
|
||||||
|
keyring::Entry::new(SERVICE, "refresh").map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_refresh() -> Result<Option<String>, String> {
|
||||||
|
match refresh_entry()?.get_password() {
|
||||||
|
Ok(s) => Ok(Some(s)),
|
||||||
|
Err(keyring::Error::NoEntry) => Ok(None),
|
||||||
|
Err(e) => Err(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_refresh(token: Option<&str>) -> Result<(), String> {
|
||||||
|
let entry = refresh_entry()?;
|
||||||
|
match token {
|
||||||
|
Some(t) => entry.set_password(t).map_err(|e| e.to_string()),
|
||||||
|
None => match entry.delete_credential() {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(keyring::Error::NoEntry) => Ok(()),
|
||||||
|
Err(e) => Err(e.to_string()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_user(app: &AppHandle) -> Result<Option<String>, String> {
|
||||||
|
let path = config_dir(app)?.join("user.json");
|
||||||
|
match fs::read_to_string(path) {
|
||||||
|
Ok(s) => Ok(Some(s)),
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||||
|
Err(e) => Err(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_user(app: &AppHandle, json: Option<&str>) -> Result<(), String> {
|
||||||
|
let path = config_dir(app)?.join("user.json");
|
||||||
|
match json {
|
||||||
|
Some(s) => fs::write(path, s).map_err(|e| e.to_string()),
|
||||||
|
None => match fs::remove_file(path) {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||||
|
Err(e) => Err(e.to_string()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"productName": "Pipu Music",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"identifier": "app.pipu.music.desktop",
|
||||||
|
"build": {
|
||||||
|
"beforeDevCommand": "pnpm --filter @pipu/web dev",
|
||||||
|
"devUrl": "http://127.0.0.1:5173",
|
||||||
|
"beforeBuildCommand": "pnpm --filter @pipu/web build",
|
||||||
|
"frontendDist": "../../web/dist"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"withGlobalTauri": true,
|
||||||
|
"windows": [{ "label": "main", "title": "Pipu Music", "width": 1200, "height": 800, "minWidth": 720, "minHeight": 540 }],
|
||||||
|
"security": {
|
||||||
|
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http://127.0.0.1:*; font-src 'self' data:; media-src 'self' blob: http://127.0.0.1:*; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:*; object-src 'none'; base-uri 'self'; frame-src 'none'"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"targets": ["nsis", "appimage", "deb", "rpm"],
|
||||||
|
"category": "Music",
|
||||||
|
"shortDescription": "Self-hosted music player",
|
||||||
|
"icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.ico", "icons/icon.icns"],
|
||||||
|
"linux": {
|
||||||
|
"deb": { "depends": ["libwebkit2gtk-4.1-0", "libayatana-appindicator3-1", "libsecret-1-0", "gstreamer1.0-plugins-good", "gstreamer1.0-plugins-bad", "gstreamer1.0-libav"] },
|
||||||
|
"appimage": { "bundleMediaFramework": true }
|
||||||
|
},
|
||||||
|
"windows": { "nsis": { "installMode": "currentUser", "languages": ["English", "Russian"], "displayLanguageSelector": true } }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,9 @@ const MyUploadsPage = lazy(() => import('../pages/uploads/MyUploadsPage').then((
|
|||||||
const SuggestPage = lazy(() => import('../pages/uploads/SuggestPage').then((m) => ({ default: m.SuggestPage })));
|
const SuggestPage = lazy(() => import('../pages/uploads/SuggestPage').then((m) => ({ default: m.SuggestPage })));
|
||||||
const SourcesPage = lazy(() => import('../pages/admin/SourcesPage').then((m) => ({ default: m.SourcesPage })));
|
const SourcesPage = lazy(() => import('../pages/admin/SourcesPage').then((m) => ({ default: m.SourcesPage })));
|
||||||
const UsersPage = lazy(() => import('../pages/admin/UsersPage').then((m) => ({ default: m.UsersPage })));
|
const UsersPage = lazy(() => import('../pages/admin/UsersPage').then((m) => ({ default: m.UsersPage })));
|
||||||
|
import type { DesktopHandle } from '../desktop';
|
||||||
import { AlbumPage } from '../pages/AlbumPage';
|
import { AlbumPage } from '../pages/AlbumPage';
|
||||||
|
import { ConnectPage } from '../pages/ConnectPage';
|
||||||
import { TrackPage } from '../pages/TrackPage';
|
import { TrackPage } from '../pages/TrackPage';
|
||||||
import { ArtistPage } from '../pages/ArtistPage';
|
import { ArtistPage } from '../pages/ArtistPage';
|
||||||
import { CollectionPage } from '../pages/CollectionPage';
|
import { CollectionPage } from '../pages/CollectionPage';
|
||||||
@@ -34,6 +36,7 @@ import { RouteError } from '../widgets/RouteError';
|
|||||||
|
|
||||||
export interface RouterContext {
|
export interface RouterContext {
|
||||||
session: AuthSession;
|
session: AuthSession;
|
||||||
|
desktop: DesktopHandle | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Куда пользователю можно попасть сейчас: обязательные шаги первого входа важнее всего. */
|
/** Куда пользователю можно попасть сейчас: обязательные шаги первого входа важнее всего. */
|
||||||
@@ -52,9 +55,12 @@ export function safeRedirect(target: unknown): string {
|
|||||||
return typeof target === 'string' && target.startsWith('/') && !target.startsWith('//') ? target : '/';
|
return typeof target === 'string' && target.startsWith('/') && !target.startsWith('//') ? target : '/';
|
||||||
}
|
}
|
||||||
|
|
||||||
function requireUser(session: AuthSession, location: ParsedLocation): AuthUser {
|
function requireUser(session: AuthSession, location: ParsedLocation, desktop: DesktopHandle | null): AuthUser {
|
||||||
const user = signedInUser(session);
|
const user = signedInUser(session);
|
||||||
if (!user) throw redirect({ to: '/login', search: { redirect: location.href } });
|
if (!user) {
|
||||||
|
if (desktop && !desktop.server) throw redirect({ to: '/connect' });
|
||||||
|
throw redirect({ to: '/login', search: { redirect: location.href } });
|
||||||
|
}
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,12 +72,23 @@ const rootRoute = createRootRouteWithContext<RouterContext>()({
|
|||||||
// Публичная: ссылку на приложение отправляют тем, у кого ещё нет аккаунта на этом устройстве.
|
// Публичная: ссылку на приложение отправляют тем, у кого ещё нет аккаунта на этом устройстве.
|
||||||
const androidAppRoute = createRoute({ getParentRoute: () => rootRoute, path: '/android', component: AppDownloadPage });
|
const androidAppRoute = createRoute({ getParentRoute: () => rootRoute, path: '/android', component: AppDownloadPage });
|
||||||
|
|
||||||
|
const connectRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/connect',
|
||||||
|
beforeLoad: ({ context }) => {
|
||||||
|
if (!context.desktop) throw redirect({ to: '/login' });
|
||||||
|
if (context.desktop.server) throw redirect({ to: '/login' });
|
||||||
|
},
|
||||||
|
component: ConnectPage,
|
||||||
|
});
|
||||||
|
|
||||||
const loginRoute = createRoute({
|
const loginRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/login',
|
path: '/login',
|
||||||
validateSearch: (search: Record<string, unknown>): { redirect?: string } =>
|
validateSearch: (search: Record<string, unknown>): { redirect?: string } =>
|
||||||
typeof search.redirect === 'string' ? { redirect: search.redirect } : {},
|
typeof search.redirect === 'string' ? { redirect: search.redirect } : {},
|
||||||
beforeLoad: ({ context, search }) => {
|
beforeLoad: ({ context, search }) => {
|
||||||
|
if (context.desktop && !context.desktop.server) throw redirect({ to: '/connect' });
|
||||||
const user = signedInUser(context.session);
|
const user = signedInUser(context.session);
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
const home = homeFor(user);
|
const home = homeFor(user);
|
||||||
@@ -85,7 +102,7 @@ const setupAdminRoute = createRoute({
|
|||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/setup/admin',
|
path: '/setup/admin',
|
||||||
beforeLoad: ({ context, location }) => {
|
beforeLoad: ({ context, location }) => {
|
||||||
const user = requireUser(context.session, location);
|
const user = requireUser(context.session, location, context.desktop);
|
||||||
if (homeFor(user) !== '/setup/admin') throw redirect({ to: homeFor(user) });
|
if (homeFor(user) !== '/setup/admin') throw redirect({ to: homeFor(user) });
|
||||||
},
|
},
|
||||||
component: () => <SetupPage variant="admin" />,
|
component: () => <SetupPage variant="admin" />,
|
||||||
@@ -95,7 +112,7 @@ const setupPasswordRoute = createRoute({
|
|||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/setup/password',
|
path: '/setup/password',
|
||||||
beforeLoad: ({ context, location }) => {
|
beforeLoad: ({ context, location }) => {
|
||||||
const user = requireUser(context.session, location);
|
const user = requireUser(context.session, location, context.desktop);
|
||||||
if (homeFor(user) !== '/setup/password') throw redirect({ to: homeFor(user) });
|
if (homeFor(user) !== '/setup/password') throw redirect({ to: homeFor(user) });
|
||||||
},
|
},
|
||||||
component: () => <SetupPage variant="password" />,
|
component: () => <SetupPage variant="password" />,
|
||||||
@@ -106,7 +123,7 @@ const appRoute = createRoute({
|
|||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
id: 'app',
|
id: 'app',
|
||||||
beforeLoad: ({ context, location }) => {
|
beforeLoad: ({ context, location }) => {
|
||||||
const user = requireUser(context.session, location);
|
const user = requireUser(context.session, location, context.desktop);
|
||||||
const home = homeFor(user);
|
const home = homeFor(user);
|
||||||
if (home !== '/') throw redirect({ to: home });
|
if (home !== '/') throw redirect({ to: home });
|
||||||
return { user };
|
return { user };
|
||||||
@@ -246,6 +263,7 @@ const adminSettingsRoute = createRoute({
|
|||||||
|
|
||||||
const routeTree = rootRoute.addChildren([
|
const routeTree = rootRoute.addChildren([
|
||||||
androidAppRoute,
|
androidAppRoute,
|
||||||
|
connectRoute,
|
||||||
loginRoute,
|
loginRoute,
|
||||||
setupAdminRoute,
|
setupAdminRoute,
|
||||||
setupPasswordRoute,
|
setupPasswordRoute,
|
||||||
@@ -273,8 +291,12 @@ const routeTree = rootRoute.addChildren([
|
|||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export function createAppRouter(session: AuthSession, opts: { history?: Parameters<typeof createRouter>[0]['history'] } = {}) {
|
export function createAppRouter(
|
||||||
const router = createRouter({ routeTree, context: { session }, defaultPreload: false, ...opts });
|
session: AuthSession,
|
||||||
|
opts: { history?: Parameters<typeof createRouter>[0]['history']; desktop?: DesktopHandle | null } = {},
|
||||||
|
) {
|
||||||
|
const { desktop = null, ...rest } = opts;
|
||||||
|
const router = createRouter({ routeTree, context: { session, desktop }, defaultPreload: false, ...rest });
|
||||||
// Выход, блокировка, отзыв сессии — guards пересчитываются, и пользователь уходит на /login.
|
// Выход, блокировка, отзыв сессии — guards пересчитываются, и пользователь уходит на /login.
|
||||||
session.subscribe(() => void router.invalidate());
|
session.subscribe(() => void router.invalidate());
|
||||||
return router;
|
return router;
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { createDesktop, isDesktop } from './desktop';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('desktop bridge', () => {
|
||||||
|
it('is inactive in the browser', () => {
|
||||||
|
expect(isDesktop()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bootstraps through the Tauri invoke surface', async () => {
|
||||||
|
const invoke = vi.fn(async (cmd: string) => {
|
||||||
|
if (cmd === 'bootstrap') return { proxyBase: 'http://127.0.0.1:9/abc', server: 'https://music.example' };
|
||||||
|
if (cmd === 'set_server') return 'https://music.example/pipu';
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
(window as Window & { __TAURI__?: unknown }).__TAURI__ = { core: { invoke }, event: { listen: vi.fn() } };
|
||||||
|
const desktop = await createDesktop();
|
||||||
|
expect(desktop?.proxyBase).toBe('http://127.0.0.1:9/abc');
|
||||||
|
expect(desktop?.server).toBe('https://music.example');
|
||||||
|
await desktop?.setServer('https://music.example/pipu');
|
||||||
|
expect(invoke).toHaveBeenCalledWith('set_server', { url: 'https://music.example/pipu' });
|
||||||
|
delete (window as Window & { __TAURI__?: unknown }).__TAURI__;
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import type { AuthUser, RefreshStore } from '@pipu/core';
|
||||||
|
import { createContext, createElement, useContext, type ReactNode } from 'react';
|
||||||
|
|
||||||
|
type Tauri = {
|
||||||
|
core: { invoke: (cmd: string, args?: Record<string, unknown>) => Promise<unknown> };
|
||||||
|
event: { listen: (event: string, handler: (e: { payload: string }) => void) => Promise<() => void> };
|
||||||
|
};
|
||||||
|
|
||||||
|
function api(): Tauri | undefined {
|
||||||
|
return (window as Window & { __TAURI__?: Tauri }).__TAURI__;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDesktop(): boolean {
|
||||||
|
return api() !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
|
||||||
|
const tauri = api();
|
||||||
|
if (!tauri) throw new Error('Tauri API unavailable');
|
||||||
|
return tauri.core.invoke(cmd, args) as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DesktopHandle = {
|
||||||
|
proxyBase: string;
|
||||||
|
server: string | null;
|
||||||
|
setServer: (url: string) => Promise<string>;
|
||||||
|
clearServer: () => Promise<void>;
|
||||||
|
refreshStore: RefreshStore;
|
||||||
|
listenMedia: (handler: (action: string) => void) => Promise<() => void>;
|
||||||
|
setMedia: (info: { title: string; artist: string; album: string; coverUrl?: string | null; playing: boolean }) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DesktopContext = createContext<DesktopHandle | null>(null);
|
||||||
|
|
||||||
|
export function DesktopProvider({ value, children }: { value: DesktopHandle | null; children: ReactNode }) {
|
||||||
|
return createElement(DesktopContext.Provider, { value }, children);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDesktop(): DesktopHandle | null {
|
||||||
|
return useContext(DesktopContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
let active: DesktopHandle | null = null;
|
||||||
|
|
||||||
|
export function desktopHandle(): DesktopHandle | null {
|
||||||
|
return active;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createDesktop(): Promise<DesktopHandle | null> {
|
||||||
|
if (!isDesktop()) return null;
|
||||||
|
const boot = await invoke<{ proxyBase: string; server: string | null }>('bootstrap');
|
||||||
|
const handle: DesktopHandle = {
|
||||||
|
proxyBase: boot.proxyBase,
|
||||||
|
server: boot.server,
|
||||||
|
setServer: async (url) => {
|
||||||
|
const server = await invoke<string>('set_server', { url });
|
||||||
|
handle.server = server;
|
||||||
|
return server;
|
||||||
|
},
|
||||||
|
clearServer: async () => {
|
||||||
|
await invoke('clear_server');
|
||||||
|
handle.server = null;
|
||||||
|
},
|
||||||
|
refreshStore: {
|
||||||
|
get: () => invoke<string | null>('token_get'),
|
||||||
|
set: (token) => invoke('token_set', { token }),
|
||||||
|
clear: async () => {
|
||||||
|
await invoke('token_clear');
|
||||||
|
await invoke('user_clear');
|
||||||
|
},
|
||||||
|
getUser: async () => {
|
||||||
|
const json = await invoke<string | null>('user_get');
|
||||||
|
return json ? (JSON.parse(json) as AuthUser) : null;
|
||||||
|
},
|
||||||
|
setUser: (user) => invoke('user_set', { json: JSON.stringify(user) }),
|
||||||
|
},
|
||||||
|
listenMedia: async (handler) => {
|
||||||
|
const tauri = api();
|
||||||
|
if (!tauri) return () => undefined;
|
||||||
|
return tauri.event.listen('media', (e) => handler(e.payload));
|
||||||
|
},
|
||||||
|
setMedia: (info) => {
|
||||||
|
void invoke('media_set', {
|
||||||
|
title: info.title,
|
||||||
|
artist: info.artist,
|
||||||
|
album: info.album,
|
||||||
|
coverUrl: info.coverUrl ?? null,
|
||||||
|
playing: info.playing,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
active = handle;
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
+11
-2
@@ -9,6 +9,7 @@ import { I18nextProvider } from 'react-i18next';
|
|||||||
import { App } from './App';
|
import { App } from './App';
|
||||||
import { createAppRouter } from './app/router';
|
import { createAppRouter } from './app/router';
|
||||||
import { applyTheme } from './app/session';
|
import { applyTheme } from './app/session';
|
||||||
|
import { createDesktop, DesktopProvider } from './desktop';
|
||||||
|
|
||||||
const LOCALE_KEY = 'pipu.locale';
|
const LOCALE_KEY = 'pipu.locale';
|
||||||
|
|
||||||
@@ -63,15 +64,23 @@ i18n.on('languageChanged', (l) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const session = new AuthSession({ clientType: 'web', deviceName: deviceName() });
|
const desktop = await createDesktop();
|
||||||
|
const session = new AuthSession({
|
||||||
|
clientType: desktop ? 'desktop' : 'web',
|
||||||
|
deviceName: desktop ? 'Pipu / Desktop' : deviceName(),
|
||||||
|
baseUrl: desktop?.proxyBase,
|
||||||
|
refreshStore: desktop?.refreshStore,
|
||||||
|
});
|
||||||
// До первого рендера узнаём, есть ли сессия (refresh-cookie), — guards роутера решают сразу.
|
// До первого рендера узнаём, есть ли сессия (refresh-cookie), — guards роутера решают сразу.
|
||||||
await session.restore();
|
await session.restore();
|
||||||
const router = createAppRouter(session);
|
const router = createAppRouter(session, { desktop });
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<I18nextProvider i18n={i18n}>
|
<I18nextProvider i18n={i18n}>
|
||||||
|
<DesktopProvider value={desktop}>
|
||||||
<App session={session} router={router} />
|
<App session={session} router={router} />
|
||||||
|
</DesktopProvider>
|
||||||
</I18nextProvider>
|
</I18nextProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useRouter } from '@tanstack/react-router';
|
||||||
|
|
||||||
|
import { useDesktop } from '../desktop';
|
||||||
|
import { Button, Field, FormError } from '../shared/ui/ui';
|
||||||
|
import { AuthLayout } from './AuthLayout';
|
||||||
|
import s from './auth.module.css';
|
||||||
|
|
||||||
|
export function ConnectPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const desktop = useDesktop();
|
||||||
|
const router = useRouter();
|
||||||
|
const [address, setAddress] = useState(desktop?.server ?? '');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const submit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!desktop) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await desktop.setServer(address);
|
||||||
|
await router.invalidate();
|
||||||
|
await router.navigate({ to: '/login' });
|
||||||
|
} catch (err) {
|
||||||
|
const text = err instanceof Error ? err.message : '';
|
||||||
|
setError(/Invalid|query|fragment|credentials/i.test(text) ? t('server.invalid_address') : t('server.offline'));
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthLayout>
|
||||||
|
<h1>{t('server.title')}</h1>
|
||||||
|
<p className={s.lead}>{t('app.tagline')}</p>
|
||||||
|
<form onSubmit={(e) => void submit(e)} noValidate>
|
||||||
|
<Field
|
||||||
|
label={t('server.address')}
|
||||||
|
value={address}
|
||||||
|
onChange={setAddress}
|
||||||
|
autoComplete="url"
|
||||||
|
autoCapitalize="none"
|
||||||
|
spellCheck={false}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<FormError>{error}</FormError>
|
||||||
|
<Button type="submit" variant="primary" className={s.submit} busy={busy} disabled={!address.trim()}>
|
||||||
|
{t('mobile.connect')}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</AuthLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { useState, type FormEvent } from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { useSession } from '../app/session';
|
import { useSession } from '../app/session';
|
||||||
|
import { useDesktop } from '../desktop';
|
||||||
import { useErrorText } from '../shared/lib/errors';
|
import { useErrorText } from '../shared/lib/errors';
|
||||||
import { Button, Field, FormError } from '../shared/ui/ui';
|
import { Button, Field, FormError } from '../shared/ui/ui';
|
||||||
import { AuthLayout } from './AuthLayout';
|
import { AuthLayout } from './AuthLayout';
|
||||||
@@ -11,6 +12,7 @@ import s from './auth.module.css';
|
|||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
const { t } = useTranslation(['auth', 'common']);
|
const { t } = useTranslation(['auth', 'common']);
|
||||||
const session = useSession();
|
const session = useSession();
|
||||||
|
const desktop = useDesktop();
|
||||||
const errorText = useErrorText();
|
const errorText = useErrorText();
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
@@ -41,7 +43,13 @@ export function LoginPage() {
|
|||||||
footer={
|
footer={
|
||||||
info.data ? (
|
info.data ? (
|
||||||
<>
|
<>
|
||||||
pipu {info.data.version} · <a href="/android">{t('app.link')}</a>
|
pipu {info.data.version}
|
||||||
|
{desktop ? null : (
|
||||||
|
<>
|
||||||
|
{' · '}
|
||||||
|
<a href="/android">{t('app.link')}</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import type { components } from '@pipu/api-client';
|
import type { components } from '@pipu/api-client';
|
||||||
import { unwrap } from '@pipu/core';
|
import { unwrap } from '@pipu/core';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
import { useState, type FormEvent } from 'react';
|
import { useState, type FormEvent } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { useSession, useUser } from '../app/session';
|
import { useSession, useUser } from '../app/session';
|
||||||
|
import { useDesktop } from '../desktop';
|
||||||
import { useErrorText, useFieldError } from '../shared/lib/errors';
|
import { useErrorText, useFieldError } from '../shared/lib/errors';
|
||||||
import { formatDateTime } from '../shared/lib/format';
|
import { formatDateTime } from '../shared/lib/format';
|
||||||
import { Button, Field, FormError, Segmented } from '../shared/ui/ui';
|
import { Button, Field, FormError, Segmented } from '../shared/ui/ui';
|
||||||
@@ -297,6 +299,8 @@ function PasswordSection() {
|
|||||||
function SessionsSection() {
|
function SessionsSection() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const session = useSession();
|
const session = useSession();
|
||||||
|
const desktop = useDesktop();
|
||||||
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const errorText = useErrorText();
|
const errorText = useErrorText();
|
||||||
const sessions = useQuery({
|
const sessions = useQuery({
|
||||||
@@ -333,6 +337,19 @@ function SessionsSection() {
|
|||||||
</div>
|
</div>
|
||||||
<FormError>{sessions.error || revoke.error ? errorText(sessions.error ?? revoke.error) : null}</FormError>
|
<FormError>{sessions.error || revoke.error ? errorText(sessions.error ?? revoke.error) : null}</FormError>
|
||||||
<div>
|
<div>
|
||||||
|
{desktop && (
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
void (async () => {
|
||||||
|
await desktop.clearServer();
|
||||||
|
await session.logout();
|
||||||
|
await navigate({ to: '/connect' });
|
||||||
|
})();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('mobile.change_server')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button variant="danger" onClick={() => void session.logout(true)}>
|
<Button variant="danger" onClick={() => void session.logout(true)}>
|
||||||
{t('settings.logout_all')}
|
{t('settings.logout_all')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
type Track,
|
type Track,
|
||||||
} from '@pipu/core';
|
} from '@pipu/core';
|
||||||
|
|
||||||
|
import { desktopHandle } from '../desktop';
|
||||||
|
|
||||||
export type { PlayContext, Track };
|
export type { PlayContext, Track };
|
||||||
|
|
||||||
export interface WaveInfo {
|
export interface WaveInfo {
|
||||||
@@ -121,6 +123,7 @@ export class Player {
|
|||||||
};
|
};
|
||||||
this.bindAudio();
|
this.bindAudio();
|
||||||
this.bindMediaSession();
|
this.bindMediaSession();
|
||||||
|
this.bindDesktopMedia();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Запуск фоновой отправки событий (парный вызов — dispose). */
|
/** Запуск фоновой отправки событий (парный вызов — dispose). */
|
||||||
@@ -137,9 +140,13 @@ export class Player {
|
|||||||
|
|
||||||
getState = () => this.state;
|
getState = () => this.state;
|
||||||
|
|
||||||
|
private lastDesktop: { id?: string; playing?: boolean } = {};
|
||||||
|
private desktopUnlisten: (() => void) | null = null;
|
||||||
|
|
||||||
private update(patch: Partial<PlayerState>) {
|
private update(patch: Partial<PlayerState>) {
|
||||||
this.state = { ...this.state, ...patch };
|
this.state = { ...this.state, ...patch };
|
||||||
for (const fn of this.listeners) fn();
|
for (const fn of this.listeners) fn();
|
||||||
|
this.syncDesktopMedia();
|
||||||
}
|
}
|
||||||
|
|
||||||
private syncQueue() {
|
private syncQueue() {
|
||||||
@@ -678,6 +685,33 @@ export class Player {
|
|||||||
|
|
||||||
// --- Media Session ---
|
// --- Media Session ---
|
||||||
|
|
||||||
|
private bindDesktopMedia() {
|
||||||
|
void desktopHandle()?.listenMedia((action) => {
|
||||||
|
if (action === 'play') this.play();
|
||||||
|
else if (action === 'pause' || action === 'stop') this.pause();
|
||||||
|
else if (action === 'toggle') this.toggle();
|
||||||
|
else if (action === 'next') this.next();
|
||||||
|
else if (action === 'previous') this.prev();
|
||||||
|
}).then((stop) => {
|
||||||
|
this.desktopUnlisten = stop;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private syncDesktopMedia() {
|
||||||
|
const desktop = desktopHandle();
|
||||||
|
const track = this.state.track;
|
||||||
|
if (!desktop || !track) return;
|
||||||
|
if (this.lastDesktop.id === track.id && this.lastDesktop.playing === this.state.playing) return;
|
||||||
|
this.lastDesktop = { id: track.id, playing: this.state.playing };
|
||||||
|
desktop.setMedia({
|
||||||
|
title: track.title,
|
||||||
|
artist: track.artists.map((a) => a.name).join(', '),
|
||||||
|
album: track.album.title,
|
||||||
|
coverUrl: track.album.cover_url,
|
||||||
|
playing: this.state.playing,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private bindMediaSession() {
|
private bindMediaSession() {
|
||||||
const ms = typeof navigator !== 'undefined' ? navigator.mediaSession : undefined;
|
const ms = typeof navigator !== 'undefined' ? navigator.mediaSession : undefined;
|
||||||
if (!ms) return;
|
if (!ms) return;
|
||||||
@@ -855,6 +889,8 @@ export class Player {
|
|||||||
this.overlap.load();
|
this.overlap.load();
|
||||||
for (const t of this.timers) clearInterval(t);
|
for (const t of this.timers) clearInterval(t);
|
||||||
this.timers = [];
|
this.timers = [];
|
||||||
|
this.desktopUnlisten?.();
|
||||||
|
this.desktopUnlisten = null;
|
||||||
void this.events.flush();
|
void this.events.flush();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+2
-2
@@ -5,7 +5,7 @@
|
|||||||
## Суть
|
## Суть
|
||||||
|
|
||||||
Закрытое (self-hosted) музыкальное приложение для прослушивания музыки с собственного сервера.
|
Закрытое (self-hosted) музыкальное приложение для прослушивания музыки с собственного сервера.
|
||||||
Есть веб-версия и мобильное приложение для Android. Главная фишка — **«Моя волна»**: бесконечный умный поток
|
Есть веб-версия, десктоп и мобильное приложение для Android. Главная фишка — **«Моя волна»**: бесконечный умный поток
|
||||||
музыки, который подстраивается под вкус пользователя и под выбранное настроение или занятие.
|
музыки, который подстраивается под вкус пользователя и под выбранное настроение или занятие.
|
||||||
|
|
||||||
Масштаб: до ~100 пользователей, библиотека от 11 000 треков и больше.
|
Масштаб: до ~100 пользователей, библиотека от 11 000 треков и больше.
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
## Доступ и учётные записи
|
## Доступ и учётные записи
|
||||||
|
|
||||||
- При первом открытии приложение показывает **экран входа** (логин + пароль).
|
- При первом открытии приложение показывает **экран входа** (логин + пароль).
|
||||||
В мобильном приложении на этом экране также указывается **адрес сервера**.
|
В мобильном приложении и на десктопе на этом экране также указывается **адрес сервера**.
|
||||||
- **Регистрации нет.** Учётные записи создаёт администрация.
|
- **Регистрации нет.** Учётные записи создаёт администрация.
|
||||||
- Роли:
|
- Роли:
|
||||||
- **Администратор** — полный доступ; единственный, кто может создавать и назначать модераторов
|
- **Администратор** — полный доступ; единственный, кто может создавать и назначать модераторов
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ Pipu Music — self-hosted музыкальный сервис (веб + Android
|
|||||||
| [playlists.md](playlists.md) | Личные плейлисты: API, веб и Android |
|
| [playlists.md](playlists.md) | Личные плейлисты: API, веб и Android |
|
||||||
| [recommendations.md](recommendations.md) | Рекомендательная система и «Моя волна» |
|
| [recommendations.md](recommendations.md) | Рекомендательная система и «Моя волна» |
|
||||||
| [web.md](web.md) | Веб-клиент |
|
| [web.md](web.md) | Веб-клиент |
|
||||||
|
| [desktop.md](desktop.md) | Десктоп Windows / Linux / macOS (Tauri) |
|
||||||
| [mobile.md](mobile.md) | Android-приложение, офлайн, самообновление |
|
| [mobile.md](mobile.md) | Android-приложение, офлайн, самообновление |
|
||||||
| [i18n.md](i18n.md) | Локализация (ru / en, расширяемость) |
|
| [i18n.md](i18n.md) | Локализация (ru / en, расширяемость) |
|
||||||
| [install-guide.md](install-guide.md) | **Пошагово:** установка сервера на домен с HTTPS, подключение веба и Android |
|
| [install-guide.md](install-guide.md) | **Пошагово:** установка сервера на домен с HTTPS, подключение веба и Android |
|
||||||
|
|||||||
@@ -6,14 +6,14 @@
|
|||||||
- Всё разворачивается на одном сервере через Docker Compose.
|
- Всё разворачивается на одном сервере через Docker Compose.
|
||||||
- Музыка лежит на NFS-хранилищах, которые подключает администратор из админки. Для всех сервисов доступ к музыке —
|
- Музыка лежит на NFS-хранилищах, которые подключает администратор из админки. Для всех сервисов доступ к музыке —
|
||||||
**только чтение**; единственный писатель — `storage-agent`, который раскладывает одобренные треки из очереди на добавление.
|
**только чтение**; единственный писатель — `storage-agent`, который раскладывает одобренные треки из очереди на добавление.
|
||||||
- Клиенты: веб (SPA) и Android-приложение.
|
- Клиенты: веб (SPA), десктоп (Tauri) и Android-приложение.
|
||||||
|
|
||||||
## 2. Компоненты
|
## 2. Компоненты
|
||||||
|
|
||||||
```
|
```
|
||||||
┌──────────────┐ ┌────────────────┐
|
┌──────────────┐ ┌────────────────┐
|
||||||
Web SPA ─────┐ │ │ │ │
|
Web SPA ─────┐ │ │ │ │
|
||||||
├──► │ nginx │────►│ api │──► PostgreSQL (+pgvector)
|
Desktop ─────┼──► │ nginx │────►│ api │──► PostgreSQL (+pgvector)
|
||||||
Android ─────┘ │ (TLS, SPA, │ │ (Go) │──► Redis
|
Android ─────┘ │ (TLS, SPA, │ │ (Go) │──► Redis
|
||||||
│ X-Accel) │ │ │──► Meilisearch
|
│ X-Accel) │ │ │──► Meilisearch
|
||||||
└──────┬───────┘ └───┬────────┬───┘
|
└──────┬───────┘ └───┬────────┬───┘
|
||||||
@@ -82,6 +82,7 @@
|
|||||||
pipu-music/
|
pipu-music/
|
||||||
├── apps/
|
├── apps/
|
||||||
│ ├── web/ # React + Vite SPA
|
│ ├── web/ # React + Vite SPA
|
||||||
|
│ ├── desktop/ # Tauri 2: то же SPA в нативном окне
|
||||||
│ └── mobile/ # React Native (Expo) Android
|
│ └── mobile/ # React Native (Expo) Android
|
||||||
├── packages/ # общий TypeScript-код (pnpm workspaces)
|
├── packages/ # общий TypeScript-код (pnpm workspaces)
|
||||||
│ ├── api-client/ # сгенерированный из OpenAPI клиент + типы
|
│ ├── api-client/ # сгенерированный из OpenAPI клиент + типы
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Десктоп (`apps/desktop`)
|
||||||
|
|
||||||
|
Windows, Linux и macOS: тот же веб-клиент в окне [Tauri 2](https://tauri.app/). Запросы к API идут через локальный прокси на `127.0.0.1`, чтобы плеер не открывал чужой origin. Refresh-токен хранится в системной связке ключей. Тип клиента — `desktop`.
|
||||||
|
|
||||||
|
При первом запуске нужно указать адрес сервера (как в Android). Дальше вход тот же, что в вебе. Кнопка «Другой сервер» в настройках забывает адрес и токен. Трея: левый клик открывает окно; меню — открыть и выход. Клавиши медиаплеера ОС управляют воспроизведением.
|
||||||
|
|
||||||
|
Плейлисты, ссылка на трек и остальной интерфейс совпадают с вебом. Сборка подставляет версию из git-тега.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter @pipu/desktop icons
|
||||||
|
pnpm --filter @pipu/desktop dev
|
||||||
|
pnpm --filter @pipu/desktop bundle
|
||||||
|
```
|
||||||
|
|
||||||
|
На Windows получается NSIS-установщик, на Linux — AppImage, deb и rpm. Зависимости Linux: WebKitGTK 4.1, AppIndicator, libsecret, GStreamer.
|
||||||
Generated
+133
-4
@@ -18,6 +18,12 @@ importers:
|
|||||||
specifier: 5.8.3
|
specifier: 5.8.3
|
||||||
version: 5.8.3
|
version: 5.8.3
|
||||||
|
|
||||||
|
apps/desktop:
|
||||||
|
devDependencies:
|
||||||
|
'@tauri-apps/cli':
|
||||||
|
specifier: ^2.9.0
|
||||||
|
version: 2.12.3
|
||||||
|
|
||||||
apps/mobile:
|
apps/mobile:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@expo/vector-icons':
|
'@expo/vector-icons':
|
||||||
@@ -116,7 +122,7 @@ importers:
|
|||||||
version: 19.0.14
|
version: 19.0.14
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^3.2.7
|
specifier: ^3.2.7
|
||||||
version: 3.2.7(@types/node@22.20.5)(jsdom@26.1.0)(lightningcss@1.27.0)(terser@5.51.2)
|
version: 3.2.7(@types/node@22.20.5)(jsdom@26.1.0)(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2)
|
||||||
|
|
||||||
apps/web:
|
apps/web:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -1458,6 +1464,82 @@ packages:
|
|||||||
'@tanstack/virtual-core@3.19.6':
|
'@tanstack/virtual-core@3.19.6':
|
||||||
resolution: {integrity: sha512-CsTW892z23gy7IyxYTTorVu1uZWB+0N349rSMgKz4rFLPpLza7F7TpcCKeI5iUAwYQCt8QUlOU67J+KXCxtpGw==}
|
resolution: {integrity: sha512-CsTW892z23gy7IyxYTTorVu1uZWB+0N349rSMgKz4rFLPpLza7F7TpcCKeI5iUAwYQCt8QUlOU67J+KXCxtpGw==}
|
||||||
|
|
||||||
|
'@tauri-apps/cli-darwin-arm64@2.12.3':
|
||||||
|
resolution: {integrity: sha512-xmVNoJOOKzKLj7GLHXmXLimRby9M42aBIJa0sB80N7o24eKV5JiVmToKTRLVH9yhL05GZ/B6rSSOmbzNFJwBAA==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@tauri-apps/cli-darwin-x64@2.12.3':
|
||||||
|
resolution: {integrity: sha512-URd+0usfmUQQyb5kNPiKt2OT8gp7oGU7QR+qOppB6e35mpVBFFVNhWc9oWb3p0u71fDJ09DpF4T6m9xYxoIB8g==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@tauri-apps/cli-linux-arm-gnueabihf@2.12.3':
|
||||||
|
resolution: {integrity: sha512-0kwUVx2LbmOoTRlwF1oDww7th4/NSluv4ZBQ6KRZsFKg4g0TlTcIiJ0VehqkQYUeC9xIT1Mk6XtF52mI155VSQ==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@tauri-apps/cli-linux-arm64-gnu@2.12.3':
|
||||||
|
resolution: {integrity: sha512-gKSGDx6fx+HBLQmG6guChFdisbrCSdx9Yli+j5teOa64gm1CeM+C6NLru5Pj3ZkkEep05BmCFjxvX+cjqAycZQ==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@tauri-apps/cli-linux-arm64-musl@2.12.3':
|
||||||
|
resolution: {integrity: sha512-Yr59qMy/gcvAfBuFdCGlAmGiLsYWMFDbsP4bAI6iNBzcJTGrYWZGeiQhGDo29MQwfF/NUBqyM2qljXu5tZ6wjQ==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@tauri-apps/cli-linux-riscv64-gnu@2.12.3':
|
||||||
|
resolution: {integrity: sha512-jKr0/REBMnLKC0OwPQZkyge0mKDaNZ0b/AbfGwVyCgFfwS730EVLfVDo3nt7MUu6jJaytRq08+xSj5qfy+U1+Q==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [riscv64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@tauri-apps/cli-linux-x64-gnu@2.12.3':
|
||||||
|
resolution: {integrity: sha512-gTkEqJAGCMROGanxHnzaJzQ+/rXl39HWVg0/hDODS1wDWBGqoceqGC6Qm1+mQbyT11xnsB8mKu/gyVID/YpFIw==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@tauri-apps/cli-linux-x64-musl@2.12.3':
|
||||||
|
resolution: {integrity: sha512-T2dTxL1svRym71fZtaW4pZYXbthbfFsEJr1NeOUg/p7laWXZbUt/8DMSZf8recu9f7+1F/Lv00HtzsEu6vDO9w==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@tauri-apps/cli-win32-arm64-msvc@2.12.3':
|
||||||
|
resolution: {integrity: sha512-eoiQwpEZWbpsVHcM9BAzqqe4TCHszvr3rsM7pUQ4ofLK0lF1i1d/jX0Qo5DYMqcrUtwQuDdDuOCbcT0Izf1VwQ==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@tauri-apps/cli-win32-ia32-msvc@2.12.3':
|
||||||
|
resolution: {integrity: sha512-kE+KUg6Dbbkgvcw0m99wJVIToFQzJqpMBAXah74WqxaLzssNwSTUccrHp6+QVzMhjQP330tvekFmnQF+0NbE6Q==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [ia32]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@tauri-apps/cli-win32-x64-msvc@2.12.3':
|
||||||
|
resolution: {integrity: sha512-L7wndWAgX2mLW7alswpL3d1R0P9FN2WYBgHaQkAYEBxHHGkY4vFX9CjcRQ1LAxDAMIJxrY7/aDziFJeChS84NQ==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@tauri-apps/cli@2.12.3':
|
||||||
|
resolution: {integrity: sha512-f7/LsY74SVb1ChmOPw1ajOpxii0GSzDAgmWHRci8JUYuuai04rXsSbFobpmB0sP2DdlvMD/B5ZotEn8GOGMRUg==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
'@testing-library/dom@10.4.2':
|
'@testing-library/dom@10.4.2':
|
||||||
resolution: {integrity: sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==}
|
resolution: {integrity: sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -5560,6 +5642,53 @@ snapshots:
|
|||||||
|
|
||||||
'@tanstack/virtual-core@3.19.6': {}
|
'@tanstack/virtual-core@3.19.6': {}
|
||||||
|
|
||||||
|
'@tauri-apps/cli-darwin-arm64@2.12.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tauri-apps/cli-darwin-x64@2.12.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tauri-apps/cli-linux-arm-gnueabihf@2.12.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tauri-apps/cli-linux-arm64-gnu@2.12.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tauri-apps/cli-linux-arm64-musl@2.12.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tauri-apps/cli-linux-riscv64-gnu@2.12.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tauri-apps/cli-linux-x64-gnu@2.12.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tauri-apps/cli-linux-x64-musl@2.12.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tauri-apps/cli-win32-arm64-msvc@2.12.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tauri-apps/cli-win32-ia32-msvc@2.12.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tauri-apps/cli-win32-x64-msvc@2.12.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tauri-apps/cli@2.12.3':
|
||||||
|
optionalDependencies:
|
||||||
|
'@tauri-apps/cli-darwin-arm64': 2.12.3
|
||||||
|
'@tauri-apps/cli-darwin-x64': 2.12.3
|
||||||
|
'@tauri-apps/cli-linux-arm-gnueabihf': 2.12.3
|
||||||
|
'@tauri-apps/cli-linux-arm64-gnu': 2.12.3
|
||||||
|
'@tauri-apps/cli-linux-arm64-musl': 2.12.3
|
||||||
|
'@tauri-apps/cli-linux-riscv64-gnu': 2.12.3
|
||||||
|
'@tauri-apps/cli-linux-x64-gnu': 2.12.3
|
||||||
|
'@tauri-apps/cli-linux-x64-musl': 2.12.3
|
||||||
|
'@tauri-apps/cli-win32-arm64-msvc': 2.12.3
|
||||||
|
'@tauri-apps/cli-win32-ia32-msvc': 2.12.3
|
||||||
|
'@tauri-apps/cli-win32-x64-msvc': 2.12.3
|
||||||
|
|
||||||
'@testing-library/dom@10.4.2':
|
'@testing-library/dom@10.4.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/code-frame': 7.29.7
|
'@babel/code-frame': 7.29.7
|
||||||
@@ -8118,7 +8247,7 @@ snapshots:
|
|||||||
|
|
||||||
vary@1.1.2: {}
|
vary@1.1.2: {}
|
||||||
|
|
||||||
vite-node@3.2.4(@types/node@22.20.5)(lightningcss@1.27.0)(terser@5.51.2):
|
vite-node@3.2.4(@types/node@22.20.5)(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2):
|
||||||
dependencies:
|
dependencies:
|
||||||
cac: 6.7.14
|
cac: 6.7.14
|
||||||
debug: 4.4.3(supports-color@10.2.2)
|
debug: 4.4.3(supports-color@10.2.2)
|
||||||
@@ -8188,7 +8317,7 @@ snapshots:
|
|||||||
lightningcss: 1.27.0
|
lightningcss: 1.27.0
|
||||||
terser: 5.51.2
|
terser: 5.51.2
|
||||||
|
|
||||||
vitest@3.2.7(@types/node@22.20.5)(jsdom@26.1.0)(lightningcss@1.27.0)(terser@5.51.2):
|
vitest@3.2.7(@types/node@22.20.5)(jsdom@26.1.0)(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/chai': 5.2.3
|
'@types/chai': 5.2.3
|
||||||
'@vitest/expect': 3.2.7
|
'@vitest/expect': 3.2.7
|
||||||
@@ -8211,7 +8340,7 @@ snapshots:
|
|||||||
tinypool: 1.1.1
|
tinypool: 1.1.1
|
||||||
tinyrainbow: 2.0.0
|
tinyrainbow: 2.0.0
|
||||||
vite: 6.4.4(@types/node@22.20.5)(lightningcss@1.27.0)(terser@5.51.2)
|
vite: 6.4.4(@types/node@22.20.5)(lightningcss@1.27.0)(terser@5.51.2)
|
||||||
vite-node: 3.2.4(@types/node@22.20.5)(lightningcss@1.27.0)(terser@5.51.2)
|
vite-node: 3.2.4(@types/node@22.20.5)(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2)
|
||||||
why-is-node-running: 2.3.0
|
why-is-node-running: 2.3.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 22.20.5
|
'@types/node': 22.20.5
|
||||||
|
|||||||
Reference in new issue
Block a user