diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 60d6a5f..1596b96 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -20,6 +20,7 @@ tauri-plugin-window-state = "2" tauri-plugin-single-instance = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" +sha2 = "0.10" tokio = { version = "1", features = ["net", "sync", "time"] } axum = "0.8" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] } diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 65abeac..4c82cc8 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1,6 +1,7 @@ mod media; mod proxy; mod store; +mod update; use media::Media; use proxy::Proxy; @@ -110,6 +111,7 @@ pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_single_instance::init(|app, _, _| show_main(app))) .plugin(tauri_plugin_window_state::Builder::default().build()) + .manage(update::Updater::default()) .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())?; @@ -150,7 +152,8 @@ pub fn run() { 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 + bootstrap, set_server, clear_server, token_get, token_set, token_clear, user_get, user_set, user_clear, media_set, + update::update_check, update::update_install ]) .run(tauri::generate_context!()) .expect("desktop app failed to start"); diff --git a/apps/desktop/src-tauri/src/proxy.rs b/apps/desktop/src-tauri/src/proxy.rs index 5114e54..edc1ac3 100644 --- a/apps/desktop/src-tauri/src/proxy.rs +++ b/apps/desktop/src-tauri/src/proxy.rs @@ -86,7 +86,7 @@ fn cors(headers: &mut HeaderMap, origin: Option<&str>) { } 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")); + headers.insert("access-control-expose-headers", HeaderValue::from_static("content-range, accept-ranges, location, upload-offset, upload-length, tus-resumable, retry-after, x-pipu-transport-error")); } async fn forward(State(proxy): State, request: Request) -> Response { @@ -110,7 +110,11 @@ async fn forward(State(proxy): State, request: Request) -> Response { } else { match transfer(&proxy, request, tail).await { Ok(response) => response, - Err(_) => (StatusCode::BAD_GATEWAY, "Server unavailable").into_response(), + Err(_) => { + let mut response = (StatusCode::BAD_GATEWAY, "Server unavailable").into_response(); + response.headers_mut().insert("x-pipu-transport-error", HeaderValue::from_static("1")); + response + }, } }; cors(response.headers_mut(), origin.as_deref()); diff --git a/apps/desktop/src-tauri/src/update.rs b/apps/desktop/src-tauri/src/update.rs new file mode 100644 index 0000000..1209b39 --- /dev/null +++ b/apps/desktop/src-tauri/src/update.rs @@ -0,0 +1,152 @@ +//! Server-proxied releases: never accept installer paths or commands from the UI. +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::{io::Write, path::Path, time::Duration}; +use tauri::{AppHandle, Emitter, Manager, State}; +use tokio::sync::Mutex; + +#[derive(Clone, Deserialize, Serialize)] +pub struct Build { + client: String, + version: String, + filename: String, + size: u64, + sha256: Option, + download_url: String, +} +#[derive(Deserialize)] +struct Catalog { version: String, notes_md: String, clients: Vec } +#[derive(Clone, Serialize)] +pub struct Release { build: Build, notes: String } +#[derive(Default)] +pub struct Updater { release: Mutex> } + +fn client() -> &'static str { + if cfg!(target_os = "windows") { "windows" } + else if cfg!(target_os = "macos") { "macos" } else { "linux" } +} + +fn version_code(raw: &str) -> Option { + let (base, rc) = raw.trim_start_matches('v').split_once("-rc.").map_or((raw.trim_start_matches('v'), "99"), |v| v); + let parts: Vec = base.split('.').map(str::parse).collect::>().ok()?; + let rc: u64 = rc.parse().ok()?; + if parts.len() != 3 || parts[0] > 1_000_000 || parts[1] > 99 || parts[2] > 99 || rc > 99 { return None; } + Some(parts[0] * 1_000_000 + parts[1] * 10_000 + parts[2] * 100 + rc) +} + +fn http() -> Result { + 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()) +} + +#[tauri::command] +pub async fn update_check(app: AppHandle, updater: State<'_, Updater>) -> Result, String> { + let mut saved = updater.release.lock().await; + *saved = None; + let server = crate::store::load_server(&app)?.ok_or("No server selected")?; + let catalog: Catalog = http()?.get(format!("{server}/api/v1/app/clients")).send().await + .map_err(|e| e.to_string())?.error_for_status().map_err(|e| e.to_string())? + .json().await.map_err(|e| e.to_string())?; + let Some(build) = catalog.clients.into_iter().find(|b| b.client == client()) else { return Ok(None); }; + let current = version_code(&app.package_info().version.to_string()).ok_or("Invalid installed version")?; + if version_code(&build.version).ok_or("Invalid release version")? <= current { return Ok(None); } + let notes = if build.version == catalog.version { catalog.notes_md } else { String::new() }; + let release = Release { build, notes }; + validate_build(&release.build)?; + *saved = Some((server, release.clone())); + Ok(Some(release)) +} + +fn validate_build(build: &Build) -> Result<(), String> { + let safe_name = !build.filename.is_empty() && build.filename.bytes().all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b)); + let extension = Path::new(&build.filename).extension().and_then(|s| s.to_str()).unwrap_or(""); + let supported = match client() { "windows" => extension == "exe" || extension == "msi", "linux" => extension == "deb" || extension == "rpm", _ => extension == "dmg" }; + let checksum = build.sha256.as_deref().is_some_and(|s| s.len() == 64 && s.bytes().all(|c| c.is_ascii_hexdigit())); + if !safe_name || !supported || !checksum || build.size == 0 || build.size > 1024 * 1024 * 1024 { return Err("Unsupported installer or missing checksum".into()); } + // Restrict to the existing, public Pipu client-file route; no arbitrary URL from a release. + if build.download_url != format!("/api/v1/app/download/{}/{}", client(), build.version) { + return Err("Invalid installer URL".into()); + } + Ok(()) +} + +#[tauri::command] +pub async fn update_install(app: AppHandle, updater: State<'_, Updater>) -> Result<(), String> { + let saved = updater.release.lock().await; + let (server, release) = saved.as_ref().ok_or("Check for an update first")?; + if crate::store::load_server(&app)?.as_ref() != Some(server) { return Err("Server changed".into()); } + let build = &release.build; + validate_build(build)?; + let dir = app.path().app_cache_dir().map_err(|e| e.to_string())?.join("updates"); + std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + let path = dir.join(&build.filename); + let part = dir.join(format!("{}.part", uuid::Uuid::new_v4())); + let result = async { + let mut response = http()?.get(format!("{server}{}", build.download_url)).send().await + .map_err(|e| e.to_string())?.error_for_status().map_err(|e| e.to_string())?; + let mut file = std::fs::File::create(&part).map_err(|e| e.to_string())?; + let mut hash = Sha256::new(); + let mut bytes = 0u64; + while let Some(chunk) = response.chunk().await.map_err(|e| e.to_string())? { + bytes += chunk.len() as u64; + if bytes > build.size { return Err("Installer exceeds expected size".to_string()); } + hash.update(&chunk); + file.write_all(&chunk).map_err(|e| e.to_string())?; + let _ = app.emit("update-progress", (bytes as f64 / build.size as f64).to_string()); + } + if bytes != build.size || format!("{:x}", hash.finalize()) != build.sha256.as_ref().unwrap().to_lowercase() { return Err("Installer checksum mismatch".into()); } + file.sync_all().map_err(|e| e.to_string())?; + drop(file); + if path.exists() { std::fs::remove_file(&path).map_err(|e| e.to_string())?; } + std::fs::rename(&part, &path).map_err(|e| e.to_string())?; + launch(&path)?; + Ok(()) + }.await; + let _ = std::fs::remove_file(&part); + result +} + +fn launch(path: &Path) -> Result<(), String> { + #[cfg(target_os = "windows")] + { + use std::os::windows::process::CommandExt; + let mut command = if path.extension().and_then(|s| s.to_str()) == Some("msi") { + let mut cmd = std::process::Command::new("msiexec.exe"); cmd.arg("/i").arg(path); cmd + } else { std::process::Command::new(path) }; + command.creation_flags(0x08000000).spawn().map_err(|e| e.to_string())?; + } + #[cfg(target_os = "linux")] + { std::process::Command::new("xdg-open").arg(path).spawn().map_err(|e| e.to_string())?; } + #[cfg(target_os = "macos")] + { std::process::Command::new("open").arg(path).spawn().map_err(|e| e.to_string())?; } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn release_order() { + assert!(version_code("1.7.2") > version_code("1.7.2-rc.3")); + assert!(version_code("1.8.0-rc.1") > version_code("1.7.99")); + assert_eq!(version_code("1.7.1"), Some(1070199)); + assert_eq!(version_code("1.7.100"), None); + assert_eq!(version_code("1.7.1;cmd"), None); + } + #[test] + fn installer_is_restricted_to_verified_server_asset() { + let extension = match client() { "windows" => "exe", "linux" => "deb", _ => "dmg" }; + let mut build = Build { client: client().into(), version: "1.8.0".into(), filename: format!("pipu-1.8.0.{extension}"), size: 42, + sha256: Some("a".repeat(64)), download_url: format!("/api/v1/app/download/{}/1.8.0", client()) }; + assert!(validate_build(&build).is_ok()); + build.download_url = "https://other.example/installer.exe".into(); + assert!(validate_build(&build).is_err()); + build.download_url = format!("/api/v1/app/download/{}/1.8.0", client()); + build.filename = "../installer.exe".into(); + assert!(validate_build(&build).is_err()); + build.filename = format!("pipu.{extension}"); + build.sha256 = None; + assert!(validate_build(&build).is_err()); + } +} diff --git a/apps/mobile/src/ui/UpdateDialog.tsx b/apps/mobile/src/ui/UpdateDialog.tsx index 60b351a..446b80a 100644 --- a/apps/mobile/src/ui/UpdateDialog.tsx +++ b/apps/mobile/src/ui/UpdateDialog.tsx @@ -1,4 +1,5 @@ import { useEffect, useSyncExternalStore } from 'react'; +import { releaseNotesText } from '@pipu/core'; import { useTranslation } from 'react-i18next'; import { AppState, Modal, Pressable, ScrollView, Text, View } from 'react-native'; @@ -10,7 +11,6 @@ import { Button } from './kit'; export function useUpdateState() { return useSyncExternalStore(updater.subscribe, () => updater.state); } - /** Проверяет обновления при запуске и возврате в приложение; показывает диалог с «Что нового». */ export function UpdateDialog() { const colors = useColors(); @@ -32,6 +32,7 @@ export function UpdateDialog() { const { release, required } = st; const busy = st.status === 'downloading' || st.status === 'verifying'; + const notes = releaseNotesText(release.notes_md); const action = () => void updater.downloadAndInstall(session, server ?? ''); return ( @@ -57,9 +58,9 @@ export function UpdateDialog() { {t('mobile.update_title', { version: release.version })} {required && {t('mobile.update_required')}} - {release.notes_md.trim() !== '' && ( - - {plainNotes(release.notes_md)} + {notes !== '' && ( + + {notes} )} @@ -115,13 +116,3 @@ export function UpdateDialog() { ); } - -/** Заметки релиза — Markdown; в диалоге показываем текстом без разметки заголовков и выделения. */ -function plainNotes(md: string): string { - return md - .replace(/^#{1,6}\s*/gm, '') - .replace(/\*\*(.+?)\*\*/g, '$1') - .replace(/^\s*[-*]\s+/gm, '• ') - .replace(/\[(.+?)\]\(.+?\)/g, '$1') - .trim(); -} diff --git a/apps/web/e2e/desktop.spec.ts b/apps/web/e2e/desktop.spec.ts new file mode 100644 index 0000000..429f023 --- /dev/null +++ b/apps/web/e2e/desktop.spec.ts @@ -0,0 +1,63 @@ +import { expect, test } from '@playwright/test'; + +test('desktop files survive restart, isolate owners and clear only recent cache', async ({ page }) => { + await page.goto('/login'); + const saved = await page.evaluate(async () => { + const source = '/src/features/desktop/offline.ts'; + const { DesktopOffline } = await import(source); + const track = { id: 'cached-track', title: 'Offline song', available: true, stream_url: 'data:audio/wav;base64,UklGRg==', duration_ms: 1000, artists: [], album: { id: 'album', title: 'Album', cover_url: null }, genres: [], reaction: null }; + const session = { client: { POST: async () => ({ data: { items: [track] }, response: new Response('{}') }) } }; + const offline = new DesktopOffline('test-server/alice', session); + await offline.ready; + await offline.save(track, true); + await offline.save({ ...track, id: 'recent-track' }); + const tracks = offline.tracks(); + const result = { count: tracks.length, blob: tracks[0].stream_url.startsWith('blob:'), pinned: offline.pinned(track.id) }; + offline.dispose(); + return result; + }); + expect(saved).toEqual({ count: 2, blob: true, pinned: true }); + await page.reload(); + const restored = await page.evaluate(async () => { + const source = '/src/features/desktop/offline.ts'; + const { DesktopOffline } = await import(source); + const session = { client: { POST: async () => { throw new TypeError('offline'); } } }; + const alice = new DesktopOffline('test-server/alice', session); + const bob = new DesktopOffline('test-server/bob', session); + await Promise.all([alice.ready, bob.ready]); + const before = alice.tracks().length; + const body = await (await fetch(alice.tracks()[0].stream_url)).blob(); + await alice.clearRecent(); + const result = { before, bytes: body.size, after: alice.tracks().map((t: { id: string }) => t.id), other: bob.tracks().length }; + alice.dispose(); bob.dispose(); + return result; + }); + expect(restored).toEqual({ before: 2, bytes: 4, after: ['cached-track'], other: 0 }); +}); + +test('desktop update dialog hides build reports and launches installer', async ({ page }) => { + await page.addInitScript(() => { + localStorage.setItem('pipu.locale', 'ru'); + (window as Window & { __TAURI__?: unknown }).__TAURI__ = { + core: { invoke: async (command: string) => { + if (command === 'bootstrap') return { proxyBase: location.origin, server: 'https://music.example' }; + if (command === 'update_check') return { build: { version: '1.8.0', size: 1048576 }, notes: '\n> APK SHA-256 abc\n\n## Изменения\n- **Офлайн-музыка**' }; + if (command === 'update_install') document.documentElement.dataset.installer = 'opened'; + return null; + } }, + event: { listen: async () => () => undefined }, + }; + }); + await page.goto('/login'); + await page.getByLabel('Логин').fill('alice'); + await page.getByRole('textbox', { name: 'Пароль' }).fill('alice-password'); + await page.getByRole('button', { name: 'Войти' }).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toContainText('Доступна версия 1.8.0'); + await expect(dialog).toContainText('• Офлайн-музыка'); + await expect(dialog).not.toContainText('android-status'); + await expect(dialog).not.toContainText('SHA-256'); + await dialog.getByRole('button', { name: 'Скачать и установить' }).click(); + await expect(dialog).toContainText('Установщик открыт'); + await expect(page.locator('html')).toHaveAttribute('data-installer', 'opened'); +}); diff --git a/apps/web/src/app/router.tsx b/apps/web/src/app/router.tsx index 4a0440f..4e114cd 100644 --- a/apps/web/src/app/router.tsx +++ b/apps/web/src/app/router.tsx @@ -33,6 +33,7 @@ import { SettingsPage } from '../pages/SettingsPage'; import { SetupPage } from '../pages/SetupPage'; import { AppShell } from '../widgets/AppShell'; import { RouteError } from '../widgets/RouteError'; +import { DesktopDownloadsPage } from '../features/desktop/DesktopFeatures'; export interface RouterContext { session: AuthSession; @@ -147,6 +148,9 @@ const homeRoute = createRoute({ getParentRoute: () => appRoute, path: '/', compo const trackRoute = createRoute({ getParentRoute: () => appRoute, path: '/track/$id', component: () => }); const sharedPlaylistRoute = createRoute({ getParentRoute: () => appRoute, path: '/shared/playlist/$shareId', component: () => }); const playlistsRoute = createRoute({ getParentRoute: () => appRoute, path: '/collection/playlists', component: PlaylistsPage }); +const downloadsRoute = createRoute({ getParentRoute: () => appRoute, path: '/collection/downloads', component: DesktopDownloadsPage, + beforeLoad: ({ context }) => { if (!context.desktop) throw redirect({ to: '/settings' }); }, +}); const playlistRoute = createRoute({ getParentRoute: () => appRoute, path: '/playlist/$id', component: () => }); const settingsRoute = createRoute({ getParentRoute: () => appRoute, path: '/settings', component: SettingsPage }); @@ -281,6 +285,7 @@ const routeTree = rootRoute.addChildren([ homeRoute, trackRoute, playlistsRoute, + downloadsRoute, sharedPlaylistRoute, playlistRoute, settingsRoute, diff --git a/apps/web/src/desktop.test.ts b/apps/web/src/desktop.test.ts index 6a358bd..25ba519 100644 --- a/apps/web/src/desktop.test.ts +++ b/apps/web/src/desktop.test.ts @@ -1,12 +1,29 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; +import { AuthSession, type AuthUser } from '@pipu/core'; -import { createDesktop, isDesktop } from './desktop'; +import { createDesktop, desktopFetch, isDesktop } from './desktop'; afterEach(() => { vi.unstubAllGlobals(); }); describe('desktop bridge', () => { + it('restores the last desktop user when the proxy cannot reach the server', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('offline', { status: 502, headers: { 'x-pipu-transport-error': '1' } }))); + const user = { id: 'alice', username: 'alice' } as AuthUser; + const clear = vi.fn(); + const session = new AuthSession({ clientType: 'desktop', fetch: desktopFetch, baseUrl: 'http://local', refreshStore: { + get: async () => 'refresh', set: vi.fn(), clear, getUser: async () => user, + } }); + expect(await session.restore()).toEqual({ status: 'signed_in', offline: true, user }); + expect(clear).not.toHaveBeenCalled(); + }); + it('reports a failed proxy hop as a network failure, preserving real HTTP errors', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('offline', { status: 502, headers: { 'x-pipu-transport-error': '1' } }))); + await expect(desktopFetch('http://local/api')).rejects.toThrow('Server unavailable'); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('denied', { status: 401 }))); + expect((await desktopFetch('http://local/api')).status).toBe(401); + }); it('is inactive in the browser', () => { expect(isDesktop()).toBe(false); }); diff --git a/apps/web/src/desktop.ts b/apps/web/src/desktop.ts index d08d6fb..99370b9 100644 --- a/apps/web/src/desktop.ts +++ b/apps/web/src/desktop.ts @@ -14,11 +14,19 @@ export function isDesktop(): boolean { return api() !== undefined; } -function invoke(cmd: string, args?: Record): Promise { +/** A failed loopback hop must behave like a network failure to offline auth restore. */ +export const desktopFetch: typeof fetch = async (input, init) => { + const response = await fetch(input, init); + if (response.headers.get('x-pipu-transport-error') === '1') throw new TypeError('Server unavailable'); + return response; +}; + +export function desktopInvoke(cmd: string, args?: Record): Promise { const tauri = api(); if (!tauri) throw new Error('Tauri API unavailable'); return tauri.core.invoke(cmd, args) as Promise; } +const invoke = desktopInvoke; export type DesktopHandle = { proxyBase: string; diff --git a/apps/web/src/entities/catalog.tsx b/apps/web/src/entities/catalog.tsx index 1202600..724fda9 100644 --- a/apps/web/src/entities/catalog.tsx +++ b/apps/web/src/entities/catalog.tsx @@ -4,6 +4,7 @@ import { Link } from '@tanstack/react-router'; import { useVirtualizer } from '@tanstack/react-virtual'; import { Fragment, useRef, useState, type CSSProperties, type MouseEvent, type ReactNode } from 'react'; import { AddToPlaylist } from './playlists'; +import { SaveOfflineButton } from '../features/desktop/DesktopFeatures'; import { useTranslation } from 'react-i18next'; import { useSession } from '../app/session'; @@ -218,6 +219,7 @@ export function TrackList({ )} + {!actions && } {actions ? actions(tr, i) : } {disliked ? ( + {error && {t('desktop.download_failed')}} + ; +} + +export function DesktopOfflineSection() { + const offline = useOffline(); + const { track } = usePlayerState(); + const { t, i18n } = useTranslation(); + const [error, setError] = useState(false); + useEffect(() => { void offline?.ready.catch(() => setError(true)); }, [offline]); + if (!offline) return null; + const tracks = offline.tracks(); + return
+

{t('desktop.offline')}

+

{t('desktop.cache_hint')}

+
+ {formatBytes(offline.bytes, i18n.language)} + + {track && } +
+ {error ? t('desktop.storage_failed') : null} + {tracks.length > 0 ? } /> :

{t('desktop.offline_empty')}

} +
; +} + +export function DesktopDownloadsPage() { + const { t } = useTranslation(); + return

{t('desktop.offline')}

; +} + +export function DesktopOfflineBanner() { + const state = useAuthState(); + const desktop = useDesktop(); + const { t } = useTranslation(); + return desktop && state.status === 'signed_in' && state.offline ?

{t('mobile.offline')}

: null; +} + +type Release = { build: { version: string; size: number }; notes: string }; +type Update = { status: 'idle' | 'checking' | 'latest' | 'error' | 'available' | 'installing' | 'ready'; release?: Release; dismissed?: boolean; progress?: number }; +let update: Update = { status: 'idle' }; +let generation = 0; +const listeners = new Set<() => void>(); +function setUpdate(value: Update) { update = value; for (const fn of listeners) fn(); } +const subscribe = (fn: () => void) => { listeners.add(fn); return () => { listeners.delete(fn); }; }; +function useUpdate() { return useSyncExternalStore(subscribe, () => update); } +async function checkUpdate(server: string, manual = false) { + if (update.status === 'checking' || update.status === 'installing') return; + const key = `pipu.update.checked.${server}`; + if (!manual && Date.now() - Number(localStorage.getItem(key) ?? 0) < 6 * 60 * 60 * 1000) return; + setUpdate({ status: 'checking' }); + const requestGeneration = generation; + try { + const release = await desktopInvoke('update_check'); + if (requestGeneration !== generation) return; + localStorage.setItem(key, String(Date.now())); + setUpdate(release ? { status: 'available', release } : { status: manual ? 'latest' : 'idle' }); + } catch { if (requestGeneration === generation) setUpdate({ status: manual ? 'error' : 'idle' }); } +} + +export function DesktopUpdateButton() { + const desktop = useDesktop(); + const state = useUpdate(); + const { t } = useTranslation(); + if (!desktop?.server) return null; + return
+

{t('desktop.updates')}

+ + {state.status === 'latest' &&

{t('mobile.update_latest')}

} + {state.status === 'error' && !state.release && {t('desktop.check_failed')}} +
; +} + +export function DesktopUpdateDialog() { + const desktop = useDesktop(); + const session = useSession(); + const state = useUpdate(); + const { t } = useTranslation(); + useEffect(() => { + if (!desktop?.server) return; + const check = () => void checkUpdate(desktop.server!); + check(); + window.addEventListener('focus', check); + return () => { window.removeEventListener('focus', check); generation++; setUpdate({ status: 'idle' }); }; + }, [desktop, session]); + useEffect(() => { + if (!desktop) return; + let stop: (() => void) | undefined; + let disposed = false; + // The native updater emits progress through the same Tauri event surface as media controls. + const api = (window as Window & { __TAURI__?: { event: { listen: (name: string, fn: (e: { payload: string }) => void) => Promise<() => void> } } }).__TAURI__; + void api?.event.listen('update-progress', (e) => { + const progress = Number(e.payload); + if (update.status === 'installing' && Number.isFinite(progress)) setUpdate({ ...update, progress: Math.min(1, Math.max(0, progress)) }); + }).then((fn) => { if (disposed) fn(); else stop = fn; }); + return () => { disposed = true; stop?.(); }; + }, [desktop]); + if (!desktop || !state.release || state.dismissed) return null; + const release = state.release; + const busy = state.status === 'installing'; + const close = () => { if (!busy) setUpdate({ ...state, dismissed: true }); }; + const notes = releaseNotesText(release.notes); + return + + + }> + {notes &&

{notes}

} +

{t('mobile.update_size', { size: (release.build.size / 1048576).toFixed(1) })}

+ {busy &&

{t('mobile.update_downloading', { percent: Math.round((state.progress ?? 0) * 100) })}

} + {state.status === 'ready' &&

{t('desktop.installer_open')}

} + {state.status === 'error' && {t('mobile.update_failed')}} +
; +} diff --git a/apps/web/src/features/desktop/offline.test.ts b/apps/web/src/features/desktop/offline.test.ts new file mode 100644 index 0000000..bebdc8c --- /dev/null +++ b/apps/web/src/features/desktop/offline.test.ts @@ -0,0 +1,30 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { AuthSession, PlayEvent } from '@pipu/core'; +import { desktopEventSink, evictRecent, offlineOwner } from './offline'; +import { createDesktop } from '../../desktop'; +import { albumTracks } from '../../test/fakeApi'; + +afterEach(() => { + localStorage.clear(); + delete (window as Window & { __TAURI__?: unknown }).__TAURI__; +}); + +describe('desktop cache policy', () => { + it('evicts oldest recent files while keeping manual downloads outside the limit', () => { + const entry = (id: string, used: number, pinned = false) => ({ id, used, pinned, track: albumTracks[0]!, audio: { size: 60 } as Blob }); + expect(evictRecent([entry('new', 3), entry('manual', 0, true), entry('old', 1), entry('middle', 2)], 100)).toEqual(['old', 'middle']); + }); +}); + +it('persists offline events across sink restarts and deduplicates retries', async () => { + (window as Window & { __TAURI__?: unknown }).__TAURI__ = { core: { invoke: async () => ({ proxyBase: 'http://local', server: 'https://music.example' }) } }; + await createDesktop(); + const post = vi.fn().mockRejectedValueOnce(new TypeError('offline')).mockResolvedValue({ data: {}, response: new Response('{}') }); + const session = { state: { status: 'signed_in', user: { id: 'alice' } }, client: { POST: post } } as unknown as AuthSession; + const owner = offlineOwner(session)!; + const event: PlayEvent = { id: 'one', type: 'play_start', occurred_at: new Date().toISOString(), track_id: 'track' }; + await expect(desktopEventSink(session, owner)([event])).rejects.toThrow('offline'); + await desktopEventSink(session, owner)([event]); + expect(post.mock.calls[1]?.[1].body.events).toEqual([event]); + expect(JSON.parse(localStorage.getItem(`pipu.events.${owner}`)!)).toEqual([]); +}); diff --git a/apps/web/src/features/desktop/offline.ts b/apps/web/src/features/desktop/offline.ts new file mode 100644 index 0000000..5208327 --- /dev/null +++ b/apps/web/src/features/desktop/offline.ts @@ -0,0 +1,181 @@ +import { unwrap, type AuthSession, type PlayEvent, type Track } from '@pipu/core'; +import { desktopHandle } from '../../desktop'; + +export const RECENT_LIMIT = 500 * 1024 * 1024; +type Entry = { id: string; track: Track; audio: Blob; cover?: Blob; pinned: boolean; used: number }; +const size = (e: Entry) => e.audio.size + (e.cover?.size ?? 0); + +export function evictRecent(entries: Entry[], limit = RECENT_LIMIT): string[] { + let total = entries.filter((e) => !e.pinned).reduce((n, e) => n + size(e), 0); + const removed: string[] = []; + for (const e of entries.filter((e) => !e.pinned).sort((a, b) => a.used - b.used)) { + if (total <= limit) break; + removed.push(e.id); + total -= size(e); + } + return removed; +} + +export function offlineOwner(session: AuthSession): string | null { + const desktop = desktopHandle(); + return desktop?.server && session.state.status === 'signed_in' + ? JSON.stringify([desktop.server, session.state.user.id]) : null; +} + +/** WebView IndexedDB is persistent across desktop restarts; signed URLs are never stored. */ +export class DesktopOffline { + private db: Promise | null = null; + private loaded: Promise | null = null; + private entries: Entry[] = []; + private urls = new Map(); + private objects = new Map(); + private inUse = new Set(); + private listeners = new Set<() => void>(); + private revision = 0; + private writes: Promise = Promise.resolve(); + private disposed = false; + get ready(): Promise { + this.loaded ??= this.reload(); + return this.loaded; + } + activate() { this.disposed = false; } + constructor(readonly owner: string, private session: AuthSession) {} + private database(): Promise { + return this.db ??= new Promise((resolve, reject) => { + const request = indexedDB.open(`pipu.offline.${this.owner}`, 1); + request.onupgradeneeded = () => request.result.createObjectStore('tracks', { keyPath: 'id' }); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + } + subscribe = (fn: () => void) => { this.listeners.add(fn); return () => { this.listeners.delete(fn); }; }; + snapshot = () => this.revision; + private async reload() { + const db = await this.database(); + const entries = await new Promise((resolve, reject) => { + const req = db.transaction('tracks').objectStore('tracks').getAll(); + req.onsuccess = () => resolve(req.result as Entry[]); + req.onerror = () => reject(req.error); + }); + if (this.disposed) return; + this.entries = entries; + for (const e of entries) { + if (this.urls.has(e.id)) continue; + const audio = URL.createObjectURL(e.audio); + const cover = e.cover ? URL.createObjectURL(e.cover) : null; + this.objects.set(e.id, [...(this.objects.get(e.id) ?? []), audio, ...(cover ? [cover] : [])]); + this.urls.set(e.id, { ...e.track, stream_url: audio, available: true, album: { ...e.track.album, cover_url: cover } }); + } + for (const id of this.urls.keys()) if (!entries.some((e) => e.id === id)) { + this.urls.delete(id); + if (!this.inUse.has(id)) this.release(id); + } + this.revision++; + for (const fn of this.listeners) fn(); + } + tracks() { return this.entries.map((e) => this.local(e.track)); } + private release(id: string) { + for (const url of this.objects.get(id) ?? []) URL.revokeObjectURL(url); + this.objects.delete(id); + } + /** Keep only files currently playing/crossfading alive after cache deletion. */ + keep(ids: string[]) { + this.inUse = new Set(ids); + for (const id of this.objects.keys()) if (!this.inUse.has(id) && !this.urls.has(id)) this.release(id); + } + local(track: Track): Track { return this.urls.get(track.id) ?? track; } + has(id: string) { return this.entries.some((e) => e.id === id); } + pinned(id: string) { return this.entries.some((e) => e.id === id && e.pinned); } + get bytes() { return this.entries.reduce((n, e) => n + size(e), 0); } + private mutate(fn: (store: IDBObjectStore) => void): Promise { + const work = this.writes.catch(() => undefined).then(async () => { + const db = await this.database(); + await new Promise((resolve, reject) => { + const tx = db.transaction('tracks', 'readwrite'); + fn(tx.objectStore('tracks')); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + }); + await this.reload(); + }); + this.writes = work; + return work; + } + private downloads = new Map>(); + private downloadTail: Promise = Promise.resolve(); + async save(track: Track, pinned = false): Promise { + await this.ready; + const pending = this.downloads.get(track.id); + if (pending) { await pending; return this.save(track, pinned); } + const existing = this.entries.find((e) => e.id === track.id); + if (existing) { + await this.mutate((store) => { + const latest = this.entries.find((e) => e.id === track.id); + if (latest) store.put({ ...latest, pinned: latest.pinned || pinned, used: Date.now() }); + }); + return; + } + const job = this.downloadTail.catch(() => undefined).then(() => this.download(track, pinned)); + this.downloadTail = job; + this.downloads.set(track.id, job); + try { await job; } finally { this.downloads.delete(track.id); } + } + private async download(track: Track, pinned: boolean) { + if (this.disposed) return; + const fresh = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: [track.id] } })).items[0]; + if (!fresh?.available || !fresh.stream_url) throw new Error('Track unavailable'); + const response = await fetch(fresh.stream_url); + if (!response.ok) throw new Error('Download failed'); + const audio = await response.blob(); + if (!audio.size) throw new Error('Empty download'); + let cover: Blob | undefined; + if (fresh.album.cover_url) { + try { + const response = await fetch(`${fresh.album.cover_url}&size=300`); + if (response.ok) cover = await response.blob(); + } catch { /* Artwork is optional. */ } + } + if (this.disposed) return; + const entry: Entry = { id: track.id, track: { ...fresh, stream_url: null, album: { ...fresh.album, cover_url: null } }, audio, cover, pinned, used: Date.now() }; + await this.mutate((store) => { + const removed = evictRecent([...this.entries.filter((e) => e.id !== entry.id), entry]); + for (const id of removed) store.delete(id); + if (!removed.includes(entry.id)) store.put(entry); + }); + } + async remove(id: string) { await this.mutate((store) => store.delete(id)); } + async clearRecent() { await this.mutate((store) => { for (const e of this.entries) if (!e.pinned) store.delete(e.id); }); } + dispose() { + this.disposed = true; + queueMicrotask(() => { + if (!this.disposed) return; + for (const id of this.objects.keys()) this.release(id); + void this.downloadTail.catch(() => undefined).then(() => this.writes) + .finally(() => this.db?.then((db) => db.close())).catch(() => undefined); + }); + } +} + +/** Persist before sending; retries keep event IDs for server-side deduplication. */ +export function desktopEventSink(session: AuthSession, owner: string) { + const key = `pipu.events.${owner}`; + let sending = Promise.resolve(); + return async (events: PlayEvent[]) => { + const work = sending.catch(() => undefined).then(async () => { + const old = JSON.parse(localStorage.getItem(key) ?? '[]') as PlayEvent[]; + const merged = [...new Map([...old, ...events].map((e) => [e.id, e])).values()]; + localStorage.setItem(key, JSON.stringify(merged)); + if (offlineOwner(session) !== owner) return; + while (merged.length) { + if (offlineOwner(session) !== owner) return; + const batch = merged.slice(0, 100); + unwrap(await session.client.POST('/api/v1/events', { body: { events: batch } })); + merged.splice(0, batch.length); + localStorage.setItem(key, JSON.stringify(merged)); + } + }); + sending = work; + await work; + }; +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index a6e8a36..38c31fd 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -9,7 +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'; +import { createDesktop, DesktopProvider, desktopFetch } from './desktop'; const LOCALE_KEY = 'pipu.locale'; @@ -70,6 +70,7 @@ const session = new AuthSession({ deviceName: desktop ? 'Pipu / Desktop' : deviceName(), baseUrl: desktop?.proxyBase, refreshStore: desktop?.refreshStore, + fetch: desktop ? desktopFetch : undefined, }); // До первого рендера узнаём, есть ли сессия (refresh-cookie), — guards роутера решают сразу. await session.restore(); diff --git a/apps/web/src/pages/SettingsPage.tsx b/apps/web/src/pages/SettingsPage.tsx index 187cb4c..7e17e44 100644 --- a/apps/web/src/pages/SettingsPage.tsx +++ b/apps/web/src/pages/SettingsPage.tsx @@ -13,6 +13,7 @@ import { Button, Field, FormError, Segmented } from '../shared/ui/ui'; import { LanguageSwitch } from '../widgets/LanguageSwitch'; import s from './page.module.css'; import { Onboarding } from './Onboarding'; +import { DesktopOfflineSection, DesktopUpdateButton } from '../features/desktop/DesktopFeatures'; type Theme = NonNullable; type WaveVariant = NonNullable; @@ -27,6 +28,8 @@ export function SettingsPage() { + + diff --git a/apps/web/src/player/persistence.ts b/apps/web/src/player/persistence.ts index 494c73a..a56a8ec 100644 --- a/apps/web/src/player/persistence.ts +++ b/apps/web/src/player/persistence.ts @@ -1,5 +1,6 @@ import { unwrap, type AuthSession, type PlayContext, type RepeatMode } from '@pipu/core'; import type { PlayerState } from './types'; +import { offlineOwner } from '../features/desktop/offline'; const LOCAL_KEY = 'pipu.player'; /** Сохраняем не больше этого числа треков очереди (лимит сервера — 500). */ @@ -30,6 +31,7 @@ function deviceId(): string { } export class PlaybackPersistence { + private get localKey() { const owner = offlineOwner(this.session); return owner ? `${LOCAL_KEY}.${owner}` : LOCAL_KEY; } private readonly device = deviceId(); lastServerSave = 0; constructor(private readonly session: AuthSession, private readonly getState: () => PlayerState) {} @@ -50,7 +52,7 @@ export class PlaybackPersistence { saveLocal() { if (!this.getState().track) return; try { - localStorage.setItem(LOCAL_KEY, JSON.stringify(this.snapshot(this.getState()))); + localStorage.setItem(this.localKey, JSON.stringify(this.snapshot(this.getState()))); } catch { // недоступное хранилище не мешает воспроизведению } @@ -91,7 +93,7 @@ export class PlaybackPersistence { async restore(): Promise { let local: Saved | null = null; try { - const raw = localStorage.getItem(LOCAL_KEY); + const raw = localStorage.getItem(this.localKey); local = raw ? (JSON.parse(raw) as Saved) : null; } catch { local = null; diff --git a/apps/web/src/player/player.ts b/apps/web/src/player/player.ts index b341705..74f6251 100644 --- a/apps/web/src/player/player.ts +++ b/apps/web/src/player/player.ts @@ -18,6 +18,7 @@ import { MediaControls } from './media'; import type { PlayerState } from './types'; import { AudioEngine } from './engine'; import { PlaybackPersistence } from './persistence'; +import { DesktopOffline, desktopEventSink, offlineOwner } from '../features/desktop/offline'; export type { PlayContext, Track }; @@ -34,6 +35,8 @@ const PRELOAD_BEFORE_MS = 20_000; * Media Session и сохранение «трек и момент» (локально и на сервере). */ export class Player { + readonly offline: DesktopOffline | null; + private retryEvents: (() => Promise) | null = null; private readonly engine: AudioEngine; private get audio() { return this.engine.audio; } private get overlap() { return this.engine.overlap; } @@ -65,8 +68,16 @@ export class Player { audio?: HTMLAudioElement, ) { this.engine = new AudioEngine(audio); + const owner = offlineOwner(session); + this.offline = owner ? new DesktopOffline(owner, session) : null; + if (owner) { + const send = desktopEventSink(session, owner); + this.retryEvents = () => send([]); + this.sendEvents = send; + } this.events = new EventBuffer({ send: async (events) => { + if (this.sendEvents) return this.sendEvents(events); unwrap(await this.session.client.POST('/api/v1/events', { body: { events } })); }, }); @@ -82,9 +93,16 @@ export class Player { /** Запуск фоновой отправки событий (парный вызов — dispose). */ start() { + this.offline?.activate(); this.timers.push(setInterval(() => void this.events.flush(), FLUSH_EVERY_MS)); + if (this.retryEvents) { + void this.retryEvents().catch(() => undefined); + this.timers.push(setInterval(() => void this.retryEvents?.().catch(() => undefined), FLUSH_EVERY_MS)); + } } + private sendEvents: ((events: import('@pipu/core').PlayEvent[]) => Promise) | null = null; + // --- подписка (useSyncExternalStore) --- subscribe = (fn: () => void) => { @@ -137,11 +155,20 @@ export class Player { this.finishAttempt('skip'); this.leaveWave(); const version = this.wavePolicy.version; - const res = unwrap( - await this.session.client.POST('/api/v1/wave/sessions', { + let res; + try { + res = unwrap(await this.session.client.POST('/api/v1/wave/sessions', { body: { activity, mood, ...(seed ? { seed: { type: seed.type, id: seed.id } } : {}) }, - }), - ); + })); + } catch (error) { + if (!this.offline) throw error; + await this.offline.ready; + if (!this.wavePolicy.isCurrent(version)) return; + const tracks = this.offline.tracks().filter((t) => t.reaction !== 'dislike'); + if (!tracks.length) throw error; + this.playList(tracks.sort(() => Math.random() - 0.5), 0, { type: 'collection', id: 'offline' }); + return; + } if (!this.wavePolicy.isCurrent(version)) return; const tracks = res.tracks.filter((t) => t.available && t.stream_url); if (!tracks.length) { @@ -339,12 +366,20 @@ export class Player { private async load(autoplay: boolean, startMs = 0) { const track = this.queue.current; if (!track?.stream_url) return; + const version = this.restoreVersion; + if (this.offline) { + await this.offline.ready.catch(() => undefined); + if (this.queue.current !== track || version !== this.restoreVersion) return; + } + this.offline?.keep([track.id]); + const source = this.offline?.local(track).stream_url ?? track.stream_url; this.update({ loading: autoplay, error: null, positionMs: startMs, durationMs: track.duration_ms }); // Keep the restored element's buffer when the user presses Play. - if (this.audio.getAttribute('src') !== track.stream_url) this.audio.src = track.stream_url; + if (this.audio.getAttribute('src') !== source) this.audio.src = source; if (this.audio.currentTime !== startMs / 1000) this.audio.currentTime = startMs / 1000; this.media.updateMediaMetadata(track); if (!autoplay) return; + void this.offline?.save(track).catch(() => undefined); this.applyLoudness(); this.attempt = new PlayAttempt(track, startMs); this.preloaded = null; @@ -425,7 +460,8 @@ export class Player { const next = this.state.queue[this.state.index + 1]; if (!next?.stream_url || !next.available) return; this.fading = true; - this.overlap.src = next.stream_url; + this.offline?.keep([this.queue.current!.id, next.id]); + this.overlap.src = this.offline?.local(next).stream_url ?? next.stream_url; this.overlap.volume = 0; void this.overlap.play().catch(() => { this.fading = false; @@ -458,7 +494,8 @@ export class Player { return; } this.syncQueue(); - const src = next.stream_url; + const src = this.offline?.local(next).stream_url ?? next.stream_url; + this.offline?.keep([next.id]); if (!src) { this.fading = false; this.ignoreEnded = false; @@ -471,6 +508,7 @@ export class Player { this.attempt = new PlayAttempt(next, Math.round(at * 1000)); this.update({ track: next, loading: false, playing: true, positionMs: Math.round(at * 1000), durationMs: next.duration_ms }); this.media.updateMediaMetadata(next); + void this.offline?.save(next).catch(() => undefined); void this.audio.play().finally(() => { this.fading = false; this.ignoreEnded = false; @@ -560,7 +598,7 @@ export class Player { if (!cur || !next?.stream_url || this.preloaded === next.id) return; if (cur.duration_ms - ms > PRELOAD_BEFORE_MS) return; this.preloaded = next.id; - void fetch(next.stream_url, { headers: { Range: 'bytes=0-0' } }).catch(() => undefined); + void fetch(this.offline?.local(next).stream_url ?? next.stream_url, { headers: { Range: 'bytes=0-0' } }).catch(() => undefined); } // --- сохранение состояния --- @@ -583,10 +621,18 @@ export class Player { const version = ++this.restoreVersion; const saved = await this.persistence.restore(); if (version !== this.restoreVersion || !saved?.queue.length) return; - const res = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: saved.queue } })); + let items: Track[]; + try { + items = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: saved.queue } })).items; + } catch (error) { + if (!this.offline) throw error; + await this.offline.ready; + const cached = new Map(this.offline.tracks().map((t) => [t.id, t])); + items = saved.queue.flatMap((id) => cached.get(id) ?? []); + } if (version !== this.restoreVersion) return; const wanted = saved.queue[saved.index]; - const tracks = res.items.filter((t) => t.available && t.stream_url); + const tracks = items.filter((t) => t.available && t.stream_url); if (!tracks.length) return; const idx = Math.max(tracks.findIndex((t) => t.id === wanted), 0); this.context = saved.context; @@ -608,6 +654,7 @@ export class Player { this.fading = false; this.finishAttempt('skip'); this.engine.dispose(); + this.offline?.dispose(); this.persistence.dispose(); for (const t of this.timers) clearInterval(t); this.timers = []; diff --git a/apps/web/src/widgets/AppShell.tsx b/apps/web/src/widgets/AppShell.tsx index 07a5484..2c7eb02 100644 --- a/apps/web/src/widgets/AppShell.tsx +++ b/apps/web/src/widgets/AppShell.tsx @@ -37,6 +37,7 @@ import { Ambient } from './Ambient'; import { PlayerBar } from './PlayerBar'; import { SearchBox } from './SearchBox'; import s from './AppShell.module.css'; +import { DesktopUpdateDialog, DesktopOfflineBanner } from '../features/desktop/DesktopFeatures'; type NavPath = | '/' @@ -49,6 +50,7 @@ type NavPath = | '/uploads/mine' | '/admin/settings' | '/collection/playlists' + | '/collection/downloads' | '/collection/history' | '/collection/likes' | '/collection/dislikes'; @@ -147,6 +149,7 @@ function NavSection({ export function AppShell() { return useAuthState().status === 'signed_in' ? ( + ) : null; @@ -174,6 +177,7 @@ function Shell() { + {desktop && } st.location.pathname }); return (
+
}> diff --git a/docs/desktop.md b/docs/desktop.md index 519d38f..1f19c0d 100644 --- a/docs/desktop.md +++ b/docs/desktop.md @@ -15,3 +15,32 @@ pnpm --filter @pipu/desktop bundle На Windows получается NSIS-установщик (~3 МБ). На Linux в релиз идёт только `.deb` (~4 МБ): AppImage тащит WebKitGTK и раздувается до ~80 МБ. Зависимости пакета: WebKitGTK 4.1, AppIndicator, libsecret, GStreamer. Релиз по тегу собирает Linux `.deb` и Windows NSIS через `cargo-xwin` (`pipu-music-<версия>-windows-setup.exe`). Подпись Windows-установщика на Linux нет. Страница `/download` на сервере отдаёт те файлы, что есть в последнем релизе Gitea. + +## Офлайн и кеш + +Коллекция → **Скачанное и кеш** доступна без сервера, в том числе после перезапуска. +Полностью загруженные прослушанные треки автоматически сохраняются в IndexedDB WebView; +кеш недавних ограничен 500 МБ, при заполнении удаляются самые старые записи. +Кнопка «Скачать» закрепляет трек: такие загрузки не вытесняются кешем и удаляются вручную. +Обложки сохраняются рядом с аудио; подписанные ссылки не сохраняются. +Хранилище и локальная очередь воспроизведения разделены по адресу сервера и пользователю. + +Настройки показывают размер хранилища и позволяют очистить недавние треки, сохранив ручные загрузки. +Плеер предпочитает локальное аудио (включая crossfade); при запуске без сети восстанавливает +сессию и сохранённую очередь без автозапуска. Если сервер волны недоступен, можно запустить +перемешанные локальные треки без дизлайков. События прослушивания сохраняются локально +и досылаются с исходными идентификаторами при восстановлении связи. + +## Обновления + +При запуске и возврате в окно (не чаще раза в 6 часов), а также вручную в настройках, +нативный клиент читает `/api/v1/app/clients` через настроенный сервер Pipu. +Выбирается сборка текущей ОС, версия сравнивается с установленной с учётом `-rc.N`. +Диалог показывает пользовательские заметки без служебных блоков CI. + +«Скачать и установить» загружает сборку в каталог кеша приложения, показывает прогресс, +проверяет размер и SHA-256 и только затем открывает установщик. Без контрольной суммы +обновление не предлагается. Windows запускает EXE/MSI; Linux открывает DEB/RPM через +системный обработчик пакетов; macOS открывает DMG. Установку подтверждает пользователь +в системном интерфейсе, после неё требуется перезапуск приложения. На Linux необходим +установленный обработчик пакетов для `xdg-open`; AppImage и `.app.tar` этим потоком не поддерживаются. diff --git a/docs/mobile.md b/docs/mobile.md index fb6823e..47e8937 100644 --- a/docs/mobile.md +++ b/docs/mobile.md @@ -109,7 +109,7 @@ кэширует ответ на 10 мин и APK на диске. Так: - мобильному приложению не нужен доступ к Gitea и токен (репозиторий может быть приватным); - версия приложения согласована с версией сервера (`min_supported` из `server-info`). -2. Если `version_code` новее — диалог с changelog (Markdown из релиза), кнопки «Обновить» / «Позже». +2. Если `version_code` новее — диалог с changelog (текст из Markdown релиза без служебных CI-блоков сборок), кнопки «Обновить» / «Позже». Если текущая версия ниже `min_supported` — обновление обязательно. 3. Загрузка APK через `expo-file-system` в `cacheDirectory` с прогрессом; проверка **SHA-256** (значение из релиза). Хэш считается кусками по 1 МБ (`js-sha256`), APK целиком в память JS не читается. Загрузка идёт с `Bearer`, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5540f02..fc4e7b2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -29,3 +29,4 @@ export { initialWaveState, mix, rgb, rgba, step, WAVE_COLORS, wavePalette, waveS export { WaveCoordinator, EARLY_SKIP_MS, WAVE_REFILL_AT, type WaveInfo, type WaveOutcome, type WaveFeedback, type WaveGateway } from './wave'; export { AGE_GROUPS, GENDERS } from './onboarding'; +export { releaseNotesText } from './releases'; diff --git a/packages/core/src/releases.test.ts b/packages/core/src/releases.test.ts new file mode 100644 index 0000000..8adfef7 --- /dev/null +++ b/packages/core/src/releases.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest'; +import { releaseNotesText } from './releases'; + +describe('release notes', () => { + it('removes build reports from all platforms, preserving real changes', () => { + expect(releaseNotesText('\n> APK `file.apk` SHA-256: abc\n\n\n\n> package.deb\n\n\n## Changes\n- **Offline** [playback](https://example.com)\n- Fix `player`')).toBe('Changes\n• Offline playback\n• Fix player'); + }); + it('hides comments and handles releases containing only build reports', () => { + expect(releaseNotesText('\n> build\n')).toBe(''); + expect(releaseNotesText('\n> Hello\n\n\nWorld')).toBe('Hello\n\nWorld'); + }); +}); diff --git a/packages/core/src/releases.ts b/packages/core/src/releases.ts new file mode 100644 index 0000000..49af979 --- /dev/null +++ b/packages/core/src/releases.ts @@ -0,0 +1,16 @@ +/** Release bodies also contain CI build reports; these are not user-facing notes. */ +export function releaseNotesText(markdown: string): string { + return markdown + .replace(/[\s\S]*?/gi, '') + .replace(//g, '') + .replace(/^\s*```[^\n]*\n[\s\S]*?^\s*```\s*$/gm, '') + .replace(/^\s{0,3}#{1,6}\s+/gm, '') + .replace(/^\s*>\s?/gm, '') + .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1') + .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') + .replace(/(`+)(.*?)\1/g, '$2') + .replace(/\*\*(.*?)\*\*|__(.*?)__/g, '$1$2') + .replace(/^\s*[-*+]\s+/gm, '• ') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} diff --git a/packages/i18n/locales/en/auth.json b/packages/i18n/locales/en/auth.json index 64771bf..4c0899f 100644 --- a/packages/i18n/locales/en/auth.json +++ b/packages/i18n/locales/en/auth.json @@ -51,9 +51,9 @@ "copy_server": "Copy the server address", "step_updates": { "android": "Sign in with your account. The app offers new versions by itself.", - "windows": "Sign in with your account. Get a new version from this page.", - "linux": "Sign in with your account. Get a new version from this page.", - "macos": "Sign in with your account. Get a new version from this page." + "windows": "Sign in with your account. The app will offer new versions.", + "linux": "Sign in with your account. The app will offer new versions through your system package installer.", + "macos": "Sign in with your account. The app will offer new versions." }, "checksum": "SHA-256 checksum", "unavailable": "Apps are not available yet: release downloads are not configured on this server (docs/deployment.md).", diff --git a/packages/i18n/locales/en/common.json b/packages/i18n/locales/en/common.json index 51f4069..fc5ebf8 100644 --- a/packages/i18n/locales/en/common.json +++ b/packages/i18n/locales/en/common.json @@ -1,4 +1,17 @@ { + "desktop": { + "offline": "Downloads and cache", + "save_track": "Download", + "remove_download": "Remove download", + "download_failed": "Could not download the track", + "storage_failed": "Could not open or change the cache. Check available disk space.", + "cache_hint": "Recent tracks are cached automatically (up to 500 MB). Manual downloads remain until removed. Available offline, including after restarting the app.", + "clear_recent": "Clear recent cache", + "offline_empty": "Played and downloaded tracks will appear here.", + "updates": "App updates", + "check_failed": "Could not check for updates. Try again later.", + "installer_open": "The installer is open. Complete installation and restart the app." + }, "genius": { "description": "Lyrics, annotations and missing artwork from Genius. Local artwork takes priority.", "loading": "Loading…", "disabled": "Not configured", "paused": "Processing paused", "cooldown": "Waiting after Genius rate limit", "running": "Fetching data from Genius…", "queued": "Queue awaiting processing", "waiting": "Waiting for new tracks", diff --git a/packages/i18n/locales/ru/auth.json b/packages/i18n/locales/ru/auth.json index dc1f445..a9410d2 100644 --- a/packages/i18n/locales/ru/auth.json +++ b/packages/i18n/locales/ru/auth.json @@ -51,9 +51,9 @@ "copy_server": "Скопировать адрес сервера", "step_updates": { "android": "Войдите своим логином. Новые версии приложение предложит установить само.", - "windows": "Войдите своим логином. Новую версию скачайте с этой страницы.", - "linux": "Войдите своим логином. Новую версию скачайте с этой страницы.", - "macos": "Войдите своим логином. Новую версию скачайте с этой страницы." + "windows": "Войдите своим логином. Приложение предложит установить новые версии.", + "linux": "Войдите своим логином. Приложение предложит установить новые версии через системный установщик пакетов.", + "macos": "Войдите своим логином. Приложение предложит установить новые версии." }, "checksum": "Контрольная сумма SHA-256", "unavailable": "Приложения пока недоступны: на сервере не настроена загрузка релизов (docs/deployment.md).", diff --git a/packages/i18n/locales/ru/common.json b/packages/i18n/locales/ru/common.json index db7019c..cb88fc6 100644 --- a/packages/i18n/locales/ru/common.json +++ b/packages/i18n/locales/ru/common.json @@ -1,4 +1,17 @@ { + "desktop": { + "offline": "Скачанное и кеш", + "save_track": "Скачать", + "remove_download": "Удалить загрузку", + "download_failed": "Не удалось скачать трек", + "storage_failed": "Не удалось открыть или изменить кеш. Проверьте свободное место.", + "cache_hint": "Недавние треки сохраняются автоматически (до 500 МБ). Скачанные вручную остаются до удаления. Доступны без сети, в том числе после перезапуска.", + "clear_recent": "Очистить кеш недавних", + "offline_empty": "Здесь появятся прослушанные и скачанные треки.", + "updates": "Обновления приложения", + "check_failed": "Не удалось проверить обновления. Повторите позже.", + "installer_open": "Установщик открыт. Завершите установку и перезапустите приложение." + }, "genius": { "description": "Тексты, пояснения и недостающие картинки из Genius. Локальные обложки имеют приоритет.", "loading": "Загрузка…", "disabled": "Не подключён", "paused": "Обработка приостановлена", "cooldown": "Ожидание после ограничения Genius", "running": "Получение данных из Genius…", "queued": "Очередь ожидает обработки", "waiting": "Ожидание новых треков",