diff --git a/.gitea/workflows/ci-desktop.yml b/.gitea/workflows/ci-desktop.yml new file mode 100644 index 0000000..0991543 --- /dev/null +++ b/.gitea/workflows/ci-desktop.yml @@ -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 diff --git a/README.md b/README.md index 7354bf4..3bcbc23 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # pipu-music -Self-hosted музыкальный сервис: веб и Android-приложение, библиотека на NFS, +Self-hosted музыкальный сервис: веб, десктоп и Android-приложение, библиотека на NFS, умная бесконечная лента «Моя волна» с учётом вкуса, звучания треков и настроения. - Идея продукта: [docs/IDEA.md](docs/IDEA.md) @@ -15,6 +15,7 @@ Self-hosted музыкальный сервис: веб и Android-прилож | `services/api` | Go: публичный API (`cmd/api`) и агент хранилищ (`cmd/storage-agent`) | | `services/recommender` | Python: рекомендации (`pipu-rec serve`) и аудиоанализ (`pipu-rec analyze`) | | `apps/web` | Веб-клиент (React + Vite) | +| `apps/desktop` | Десктоп Windows / Linux / macOS (Tauri, тот же веб) | | `apps/mobile` | Android-приложение (React Native + Expo) | | `packages/*` | Общий TS-код: `api-client`, `core`, `i18n`, `design-tokens` | | `deploy/` | Docker Compose, nginx, скрипты установки и обновления | diff --git a/apps/desktop/inject-version.mjs b/apps/desktop/inject-version.mjs new file mode 100644 index 0000000..560ec3f --- /dev/null +++ b/apps/desktop/inject-version.mjs @@ -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}`); diff --git a/apps/desktop/package.json b/apps/desktop/package.json new file mode 100644 index 0000000..55bc4ff --- /dev/null +++ b/apps/desktop/package.json @@ -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" + } +} diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml new file mode 100644 index 0000000..f27dc4e --- /dev/null +++ b/apps/desktop/src-tauri/Cargo.toml @@ -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 diff --git a/apps/desktop/src-tauri/build.rs b/apps/desktop/src-tauri/build.rs new file mode 100644 index 0000000..d860e1e --- /dev/null +++ b/apps/desktop/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/apps/desktop/src-tauri/capabilities/main.json b/apps/desktop/src-tauri/capabilities/main.json new file mode 100644 index 0000000..08c10cf --- /dev/null +++ b/apps/desktop/src-tauri/capabilities/main.json @@ -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"] +} diff --git a/apps/desktop/src-tauri/icons/128x128.png b/apps/desktop/src-tauri/icons/128x128.png new file mode 100644 index 0000000..8c63fb5 Binary files /dev/null and b/apps/desktop/src-tauri/icons/128x128.png differ diff --git a/apps/desktop/src-tauri/icons/128x128@2x.png b/apps/desktop/src-tauri/icons/128x128@2x.png new file mode 100644 index 0000000..f308b2e Binary files /dev/null and b/apps/desktop/src-tauri/icons/128x128@2x.png differ diff --git a/apps/desktop/src-tauri/icons/32x32.png b/apps/desktop/src-tauri/icons/32x32.png new file mode 100644 index 0000000..5987efe Binary files /dev/null and b/apps/desktop/src-tauri/icons/32x32.png differ diff --git a/apps/desktop/src-tauri/icons/64x64.png b/apps/desktop/src-tauri/icons/64x64.png new file mode 100644 index 0000000..7c7a0db Binary files /dev/null and b/apps/desktop/src-tauri/icons/64x64.png differ diff --git a/apps/desktop/src-tauri/icons/icon.icns b/apps/desktop/src-tauri/icons/icon.icns new file mode 100644 index 0000000..f3e77c9 Binary files /dev/null and b/apps/desktop/src-tauri/icons/icon.icns differ diff --git a/apps/desktop/src-tauri/icons/icon.ico b/apps/desktop/src-tauri/icons/icon.ico new file mode 100644 index 0000000..dc81fe6 Binary files /dev/null and b/apps/desktop/src-tauri/icons/icon.ico differ diff --git a/apps/desktop/src-tauri/icons/icon.png b/apps/desktop/src-tauri/icons/icon.png new file mode 100644 index 0000000..a3c10b7 Binary files /dev/null and b/apps/desktop/src-tauri/icons/icon.png differ diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs new file mode 100644 index 0000000..769ee6c --- /dev/null +++ b/apps/desktop/src-tauri/src/lib.rs @@ -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>, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct Bootstrap { + proxy_base: String, + server: Option, +} + +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) -> Result { + 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 { + 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) -> 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, 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, 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, + title: String, + artist: String, + album: String, + cover_url: Option, + 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"); +} diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs new file mode 100644 index 0000000..9bba55a --- /dev/null +++ b/apps/desktop/src-tauri/src/main.rs @@ -0,0 +1,5 @@ +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + pipu_desktop_lib::run(); +} diff --git a/apps/desktop/src-tauri/src/media.rs b/apps/desktop/src-tauri/src/media.rs new file mode 100644 index 0000000..cf99abb --- /dev/null +++ b/apps/desktop/src-tauri/src/media.rs @@ -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>, +} + +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:?}")) + } +} diff --git a/apps/desktop/src-tauri/src/proxy.rs b/apps/desktop/src-tauri/src/proxy.rs new file mode 100644 index 0000000..d2eec8f --- /dev/null +++ b/apps/desktop/src-tauri/src/proxy.rs @@ -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>>, + pub base: String, + secret: String, + authority: String, + client: reqwest::Client, +} + +pub fn validate_server(raw: &str) -> Result { + 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) -> Result { + 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 { + 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, 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 { + 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"); + } +} diff --git a/apps/desktop/src-tauri/src/store.rs b/apps/desktop/src-tauri/src/store.rs new file mode 100644 index 0000000..74cf0c8 --- /dev/null +++ b/apps/desktop/src-tauri/src/store.rs @@ -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 { + 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, 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::new(SERVICE, "refresh").map_err(|e| e.to_string()) +} + +pub fn load_refresh() -> Result, 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, 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()), + }, + } +} diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json new file mode 100644 index 0000000..4c9f388 --- /dev/null +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -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 } } + } +} diff --git a/apps/web/src/app/router.tsx b/apps/web/src/app/router.tsx index f3646bc..99c9728 100644 --- a/apps/web/src/app/router.tsx +++ b/apps/web/src/app/router.tsx @@ -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 SourcesPage = lazy(() => import('../pages/admin/SourcesPage').then((m) => ({ default: m.SourcesPage }))); const UsersPage = lazy(() => import('../pages/admin/UsersPage').then((m) => ({ default: m.UsersPage }))); +import type { DesktopHandle } from '../desktop'; import { AlbumPage } from '../pages/AlbumPage'; +import { ConnectPage } from '../pages/ConnectPage'; import { TrackPage } from '../pages/TrackPage'; import { ArtistPage } from '../pages/ArtistPage'; import { CollectionPage } from '../pages/CollectionPage'; @@ -34,6 +36,7 @@ import { RouteError } from '../widgets/RouteError'; export interface RouterContext { session: AuthSession; + desktop: DesktopHandle | null; } /** Куда пользователю можно попасть сейчас: обязательные шаги первого входа важнее всего. */ @@ -52,9 +55,12 @@ export function safeRedirect(target: unknown): string { 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); - 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; } @@ -66,12 +72,23 @@ const rootRoute = createRootRouteWithContext()({ // Публичная: ссылку на приложение отправляют тем, у кого ещё нет аккаунта на этом устройстве. 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({ getParentRoute: () => rootRoute, path: '/login', validateSearch: (search: Record): { redirect?: string } => typeof search.redirect === 'string' ? { redirect: search.redirect } : {}, beforeLoad: ({ context, search }) => { + if (context.desktop && !context.desktop.server) throw redirect({ to: '/connect' }); const user = signedInUser(context.session); if (!user) return; const home = homeFor(user); @@ -85,7 +102,7 @@ const setupAdminRoute = createRoute({ getParentRoute: () => rootRoute, path: '/setup/admin', 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) }); }, component: () => , @@ -95,7 +112,7 @@ const setupPasswordRoute = createRoute({ getParentRoute: () => rootRoute, path: '/setup/password', 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) }); }, component: () => , @@ -106,7 +123,7 @@ const appRoute = createRoute({ getParentRoute: () => rootRoute, id: 'app', beforeLoad: ({ context, location }) => { - const user = requireUser(context.session, location); + const user = requireUser(context.session, location, context.desktop); const home = homeFor(user); if (home !== '/') throw redirect({ to: home }); return { user }; @@ -246,6 +263,7 @@ const adminSettingsRoute = createRoute({ const routeTree = rootRoute.addChildren([ androidAppRoute, + connectRoute, loginRoute, setupAdminRoute, setupPasswordRoute, @@ -273,8 +291,12 @@ const routeTree = rootRoute.addChildren([ ]), ]); -export function createAppRouter(session: AuthSession, opts: { history?: Parameters[0]['history'] } = {}) { - const router = createRouter({ routeTree, context: { session }, defaultPreload: false, ...opts }); +export function createAppRouter( + session: AuthSession, + opts: { history?: Parameters[0]['history']; desktop?: DesktopHandle | null } = {}, +) { + const { desktop = null, ...rest } = opts; + const router = createRouter({ routeTree, context: { session, desktop }, defaultPreload: false, ...rest }); // Выход, блокировка, отзыв сессии — guards пересчитываются, и пользователь уходит на /login. session.subscribe(() => void router.invalidate()); return router; diff --git a/apps/web/src/desktop.test.ts b/apps/web/src/desktop.test.ts new file mode 100644 index 0000000..6a358bd --- /dev/null +++ b/apps/web/src/desktop.test.ts @@ -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__; + }); +}); diff --git a/apps/web/src/desktop.ts b/apps/web/src/desktop.ts new file mode 100644 index 0000000..d08d6fb --- /dev/null +++ b/apps/web/src/desktop.ts @@ -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) => Promise }; + 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(cmd: string, args?: Record): Promise { + const tauri = api(); + if (!tauri) throw new Error('Tauri API unavailable'); + return tauri.core.invoke(cmd, args) as Promise; +} + +export type DesktopHandle = { + proxyBase: string; + server: string | null; + setServer: (url: string) => Promise; + clearServer: () => Promise; + 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(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 { + 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('set_server', { url }); + handle.server = server; + return server; + }, + clearServer: async () => { + await invoke('clear_server'); + handle.server = null; + }, + refreshStore: { + get: () => invoke('token_get'), + set: (token) => invoke('token_set', { token }), + clear: async () => { + await invoke('token_clear'); + await invoke('user_clear'); + }, + getUser: async () => { + const json = await invoke('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; +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index ce22b37..a6e8a36 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -9,6 +9,7 @@ import { I18nextProvider } from 'react-i18next'; import { App } from './App'; import { createAppRouter } from './app/router'; import { applyTheme } from './app/session'; +import { createDesktop, DesktopProvider } from './desktop'; 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 роутера решают сразу. await session.restore(); -const router = createAppRouter(session); +const router = createAppRouter(session, { desktop }); createRoot(document.getElementById('root')!).render( - + + + , ); diff --git a/apps/web/src/pages/ConnectPage.tsx b/apps/web/src/pages/ConnectPage.tsx new file mode 100644 index 0000000..2b4231d --- /dev/null +++ b/apps/web/src/pages/ConnectPage.tsx @@ -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(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 ( + +

{t('server.title')}

+

{t('app.tagline')}

+
void submit(e)} noValidate> + + {error} + + +
+ ); +} diff --git a/apps/web/src/pages/LoginPage.tsx b/apps/web/src/pages/LoginPage.tsx index 58fa433..496f6fe 100644 --- a/apps/web/src/pages/LoginPage.tsx +++ b/apps/web/src/pages/LoginPage.tsx @@ -3,6 +3,7 @@ import { useState, type FormEvent } from 'react'; import { useTranslation } from 'react-i18next'; import { useSession } from '../app/session'; +import { useDesktop } from '../desktop'; import { useErrorText } from '../shared/lib/errors'; import { Button, Field, FormError } from '../shared/ui/ui'; import { AuthLayout } from './AuthLayout'; @@ -11,6 +12,7 @@ import s from './auth.module.css'; export function LoginPage() { const { t } = useTranslation(['auth', 'common']); const session = useSession(); + const desktop = useDesktop(); const errorText = useErrorText(); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); @@ -41,7 +43,13 @@ export function LoginPage() { footer={ info.data ? ( <> - pipu {info.data.version} · {t('app.link')} + pipu {info.data.version} + {desktop ? null : ( + <> + {' · '} + {t('app.link')} + + )} ) : undefined } diff --git a/apps/web/src/pages/SettingsPage.tsx b/apps/web/src/pages/SettingsPage.tsx index b8a28b0..800cfe3 100644 --- a/apps/web/src/pages/SettingsPage.tsx +++ b/apps/web/src/pages/SettingsPage.tsx @@ -1,10 +1,12 @@ import type { components } from '@pipu/api-client'; import { unwrap } from '@pipu/core'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useNavigate } from '@tanstack/react-router'; import { useState, type FormEvent } from 'react'; import { useTranslation } from 'react-i18next'; import { useSession, useUser } from '../app/session'; +import { useDesktop } from '../desktop'; import { useErrorText, useFieldError } from '../shared/lib/errors'; import { formatDateTime } from '../shared/lib/format'; import { Button, Field, FormError, Segmented } from '../shared/ui/ui'; @@ -297,6 +299,8 @@ function PasswordSection() { function SessionsSection() { const { t, i18n } = useTranslation(); const session = useSession(); + const desktop = useDesktop(); + const navigate = useNavigate(); const queryClient = useQueryClient(); const errorText = useErrorText(); const sessions = useQuery({ @@ -333,6 +337,19 @@ function SessionsSection() { {sessions.error || revoke.error ? errorText(sessions.error ?? revoke.error) : null}
+ {desktop && ( + + )} diff --git a/apps/web/src/player/player.ts b/apps/web/src/player/player.ts index 2baaae1..fcbb4cc 100644 --- a/apps/web/src/player/player.ts +++ b/apps/web/src/player/player.ts @@ -10,6 +10,8 @@ import { type Track, } from '@pipu/core'; +import { desktopHandle } from '../desktop'; + export type { PlayContext, Track }; export interface WaveInfo { @@ -121,6 +123,7 @@ export class Player { }; this.bindAudio(); this.bindMediaSession(); + this.bindDesktopMedia(); } /** Запуск фоновой отправки событий (парный вызов — dispose). */ @@ -137,9 +140,13 @@ export class Player { getState = () => this.state; + private lastDesktop: { id?: string; playing?: boolean } = {}; + private desktopUnlisten: (() => void) | null = null; + private update(patch: Partial) { this.state = { ...this.state, ...patch }; for (const fn of this.listeners) fn(); + this.syncDesktopMedia(); } private syncQueue() { @@ -678,6 +685,33 @@ export class Player { // --- 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() { const ms = typeof navigator !== 'undefined' ? navigator.mediaSession : undefined; if (!ms) return; @@ -855,6 +889,8 @@ export class Player { this.overlap.load(); for (const t of this.timers) clearInterval(t); this.timers = []; + this.desktopUnlisten?.(); + this.desktopUnlisten = null; void this.events.flush(); } } diff --git a/docs/IDEA.md b/docs/IDEA.md index 5f17f52..194e96a 100644 --- a/docs/IDEA.md +++ b/docs/IDEA.md @@ -5,7 +5,7 @@ ## Суть Закрытое (self-hosted) музыкальное приложение для прослушивания музыки с собственного сервера. -Есть веб-версия и мобильное приложение для Android. Главная фишка — **«Моя волна»**: бесконечный умный поток +Есть веб-версия, десктоп и мобильное приложение для Android. Главная фишка — **«Моя волна»**: бесконечный умный поток музыки, который подстраивается под вкус пользователя и под выбранное настроение или занятие. Масштаб: до ~100 пользователей, библиотека от 11 000 треков и больше. @@ -16,7 +16,7 @@ ## Доступ и учётные записи - При первом открытии приложение показывает **экран входа** (логин + пароль). - В мобильном приложении на этом экране также указывается **адрес сервера**. + В мобильном приложении и на десктопе на этом экране также указывается **адрес сервера**. - **Регистрации нет.** Учётные записи создаёт администрация. - Роли: - **Администратор** — полный доступ; единственный, кто может создавать и назначать модераторов diff --git a/docs/README.md b/docs/README.md index cdbafcd..6d81c1f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -18,6 +18,7 @@ Pipu Music — self-hosted музыкальный сервис (веб + Android | [playlists.md](playlists.md) | Личные плейлисты: API, веб и Android | | [recommendations.md](recommendations.md) | Рекомендательная система и «Моя волна» | | [web.md](web.md) | Веб-клиент | +| [desktop.md](desktop.md) | Десктоп Windows / Linux / macOS (Tauri) | | [mobile.md](mobile.md) | Android-приложение, офлайн, самообновление | | [i18n.md](i18n.md) | Локализация (ru / en, расширяемость) | | [install-guide.md](install-guide.md) | **Пошагово:** установка сервера на домен с HTTPS, подключение веба и Android | diff --git a/docs/architecture.md b/docs/architecture.md index 53205fb..f2e9fe2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -6,14 +6,14 @@ - Всё разворачивается на одном сервере через Docker Compose. - Музыка лежит на NFS-хранилищах, которые подключает администратор из админки. Для всех сервисов доступ к музыке — **только чтение**; единственный писатель — `storage-agent`, который раскладывает одобренные треки из очереди на добавление. -- Клиенты: веб (SPA) и Android-приложение. +- Клиенты: веб (SPA), десктоп (Tauri) и Android-приложение. ## 2. Компоненты ``` ┌──────────────┐ ┌────────────────┐ Web SPA ─────┐ │ │ │ │ - ├──► │ nginx │────►│ api │──► PostgreSQL (+pgvector) + Desktop ─────┼──► │ nginx │────►│ api │──► PostgreSQL (+pgvector) Android ─────┘ │ (TLS, SPA, │ │ (Go) │──► Redis │ X-Accel) │ │ │──► Meilisearch └──────┬───────┘ └───┬────────┬───┘ @@ -82,6 +82,7 @@ pipu-music/ ├── apps/ │ ├── web/ # React + Vite SPA +│ ├── desktop/ # Tauri 2: то же SPA в нативном окне │ └── mobile/ # React Native (Expo) Android ├── packages/ # общий TypeScript-код (pnpm workspaces) │ ├── api-client/ # сгенерированный из OpenAPI клиент + типы diff --git a/docs/desktop.md b/docs/desktop.md new file mode 100644 index 0000000..d2aaaca --- /dev/null +++ b/docs/desktop.md @@ -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. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e83d80..e429f0a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,6 +18,12 @@ importers: specifier: 5.8.3 version: 5.8.3 + apps/desktop: + devDependencies: + '@tauri-apps/cli': + specifier: ^2.9.0 + version: 2.12.3 + apps/mobile: dependencies: '@expo/vector-icons': @@ -116,7 +122,7 @@ importers: version: 19.0.14 vitest: 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: dependencies: @@ -1458,6 +1464,82 @@ packages: '@tanstack/virtual-core@3.19.6': 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': resolution: {integrity: sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==} engines: {node: '>=18'} @@ -5560,6 +5642,53 @@ snapshots: '@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': dependencies: '@babel/code-frame': 7.29.7 @@ -8118,7 +8247,7 @@ snapshots: 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: cac: 6.7.14 debug: 4.4.3(supports-color@10.2.2) @@ -8188,7 +8317,7 @@ snapshots: 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)(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: '@types/chai': 5.2.3 '@vitest/expect': 3.2.7 @@ -8211,7 +8340,7 @@ snapshots: tinypool: 1.1.1 tinyrainbow: 2.0.0 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 optionalDependencies: '@types/node': 22.20.5