Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
597067689a | ||
|
|
efe78d7e07 |
No files matched your search
@@ -0,0 +1,33 @@
|
||||
# Rust-часть десктопа: прокси и проверки URL. Полная сборка инсталлятора — локально
|
||||
# (`pnpm --filter @pipu/desktop bundle`): на раннере нет Windows и WebKitGTK.
|
||||
name: ci / desktop
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'apps/desktop/**'
|
||||
- '.gitea/workflows/ci-desktop.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'apps/desktop/**'
|
||||
- '.gitea/workflows/ci-desktop.yml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
rust:
|
||||
name: rust (desktop proxy)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Rust
|
||||
run: |
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
|
||||
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||
- run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libdbus-1-dev
|
||||
- run: cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml
|
||||
@@ -57,5 +57,9 @@ jobs:
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
- run: pnpm --filter @pipu/web exec playwright install --with-deps chromium
|
||||
# git-core PPA на раннере иногда отвечает 503 и валит apt внутри --with-deps.
|
||||
- run: |
|
||||
sudo rm -f /etc/apt/sources.list.d/*git-core* || true
|
||||
pnpm --filter @pipu/web exec playwright install --with-deps chromium ||
|
||||
pnpm --filter @pipu/web exec playwright install chromium
|
||||
- run: pnpm --filter @pipu/web test:e2e
|
||||
@@ -132,9 +132,10 @@ jobs:
|
||||
# Постоянный кэш Gradle мог побиться (VM раннера перезагрузили посреди сборки): «Could not read workspace
|
||||
# metadata» — удаляем кэш трансформаций и повторяем один раз, а не падаем до ручной чистки.
|
||||
if ! "${gradle[@]}" assembleRelease -x lintVitalRelease 2>&1 | tee /tmp/gradle.log; then
|
||||
if grep -qE "Could not read workspace metadata|Corrupted|is corrupt" /tmp/gradle.log; then
|
||||
echo "::warning::кэш Gradle повреждён — чистим transforms и собираем заново"
|
||||
if grep -qE "Could not read workspace metadata|Corrupted|is corrupt|Timeout waiting to lock journal cache|currently in use by another Gradle instance" /tmp/gradle.log; then
|
||||
echo "::warning::кэш Gradle занят или повреждён — останавливаем демоны и собираем заново"
|
||||
./gradlew --stop || true
|
||||
find ~/.gradle -name "*.lock" -delete || true
|
||||
rm -rf ~/.gradle/caches/*/transforms ~/.gradle/caches/transforms-*
|
||||
"${gradle[@]}" assembleRelease -x lintVitalRelease
|
||||
else
|
||||
|
||||
@@ -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, скрипты установки и обновления |
|
||||
|
||||
+79
-1
@@ -1497,11 +1497,57 @@ paths:
|
||||
$ref: "#/components/schemas/WaveBatch"
|
||||
default:
|
||||
$ref: "#/components/responses/Problem"
|
||||
/api/v1/app/clients:
|
||||
get:
|
||||
operationId: getAppClients
|
||||
tags: [app]
|
||||
security: [] # публично: страница /download без входа
|
||||
summary: Клиенты последнего релиза (Android и десктоп)
|
||||
description: |
|
||||
Тот же релиз Gitea, что и для Android: APK, установщик Windows, AppImage/deb, DMG.
|
||||
Нет настроек Gitea или файлов клиентов — `404 app.update_unavailable`.
|
||||
responses:
|
||||
"200":
|
||||
description: Доступные сборки
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AppClients"
|
||||
default:
|
||||
$ref: "#/components/responses/Problem"
|
||||
/api/v1/app/download/{client}/{version}:
|
||||
get:
|
||||
operationId: downloadAppClient
|
||||
tags: [app]
|
||||
security: []
|
||||
summary: Файл клиента (кэшируется на сервере, поддерживает Range)
|
||||
parameters:
|
||||
- name: client
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
$ref: "#/components/schemas/AppClient"
|
||||
- name: version
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
pattern: '^\d+\.\d+\.\d+(-rc\.\d+)?$'
|
||||
responses:
|
||||
"200":
|
||||
description: Установщик или APK
|
||||
content:
|
||||
application/octet-stream:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
default:
|
||||
$ref: "#/components/responses/Problem"
|
||||
/api/v1/app/android/latest:
|
||||
get:
|
||||
operationId: getAndroidLatest
|
||||
tags: [app]
|
||||
security: [] # публично: страница /android раздаёт приложение без входа
|
||||
security: [] # публично: страница /download раздаёт приложение без входа
|
||||
summary: Последняя версия Android-приложения (из релизов Gitea через сервер)
|
||||
description: |
|
||||
Сервер сам опрашивает Gitea своим токеном и кэширует ответ на 10 минут, поэтому приложению
|
||||
@@ -3446,6 +3492,38 @@ components:
|
||||
like_rate:
|
||||
type: number
|
||||
nullable: true
|
||||
AppClient:
|
||||
type: string
|
||||
enum: [android, windows, linux, macos]
|
||||
AppClientFile:
|
||||
type: object
|
||||
required: [client, filename, size, download_url]
|
||||
properties:
|
||||
client:
|
||||
$ref: "#/components/schemas/AppClient"
|
||||
filename:
|
||||
type: string
|
||||
size:
|
||||
type: integer
|
||||
format: int64
|
||||
sha256:
|
||||
type: string
|
||||
download_url:
|
||||
type: string
|
||||
AppClients:
|
||||
type: object
|
||||
required: [version, version_code, notes_md, clients]
|
||||
properties:
|
||||
version:
|
||||
type: string
|
||||
version_code:
|
||||
type: integer
|
||||
notes_md:
|
||||
type: string
|
||||
clients:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/AppClientFile"
|
||||
AndroidRelease:
|
||||
type: object
|
||||
required: [version, version_code, notes_md, size, sha256, download_url, min_supported]
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = dirname(fileURLToPath(import.meta.url));
|
||||
const conf = JSON.parse(readFileSync(resolve(root, 'src-tauri/tauri.conf.json'), 'utf8'));
|
||||
let version = '0.0.0';
|
||||
try {
|
||||
const tag = execFileSync('git', ['describe', '--tags', '--abbrev=0'], { encoding: 'utf8' }).trim();
|
||||
version = tag.replace(/^v/, '').replace(/-rc\.\d+$/, '');
|
||||
} catch {
|
||||
// local untagged build
|
||||
}
|
||||
conf.version = version;
|
||||
writeFileSync(resolve(root, 'src-tauri/tauri.release.json'), JSON.stringify(conf, null, 2));
|
||||
console.log(`desktop version ${version}`);
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@pipu/desktop",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tauri dev",
|
||||
"bundle": "node inject-version.mjs && tauri build --config src-tauri/tauri.release.json",
|
||||
"tauri": "tauri",
|
||||
"icons": "tauri icon ../web/public/favicon.svg"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.9.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
[package]
|
||||
name = "pipu-desktop"
|
||||
version = "0.0.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "pipu_desktop_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[[bin]]
|
||||
name = "pipu-desktop"
|
||||
path = "src/main.rs"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["tray-icon"] }
|
||||
tauri-plugin-window-state = "2"
|
||||
tauri-plugin-single-instance = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["net", "sync", "time"] }
|
||||
axum = "0.8"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
url = "2"
|
||||
souvlaki = "0.8.3"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
keyring = { version = "3.6.3", features = ["windows-native"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
keyring = { version = "3.6.3", features = ["sync-secret-service", "crypto-rust"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
keyring = { version = "3.6.3", features = ["apple-native"] }
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "main",
|
||||
"description": "Only the bundled main window may invoke desktop integration.",
|
||||
"windows": ["main"],
|
||||
"permissions": ["core:default", "core:event:default"]
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
@@ -0,0 +1,157 @@
|
||||
mod media;
|
||||
mod proxy;
|
||||
mod store;
|
||||
|
||||
use media::Media;
|
||||
use proxy::Proxy;
|
||||
use serde::Serialize;
|
||||
use std::sync::Mutex;
|
||||
use tauri::{
|
||||
image::Image,
|
||||
include_image,
|
||||
menu::{Menu, MenuItem},
|
||||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||
AppHandle, Manager, State,
|
||||
};
|
||||
|
||||
struct AppState {
|
||||
proxy: Proxy,
|
||||
media: Media,
|
||||
server: Mutex<Option<String>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Bootstrap {
|
||||
proxy_base: String,
|
||||
server: Option<String>,
|
||||
}
|
||||
|
||||
fn show_main(app: &AppHandle) {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.unminimize();
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn bootstrap(app: AppHandle, state: State<AppState>) -> Result<Bootstrap, String> {
|
||||
Ok(Bootstrap {
|
||||
proxy_base: state.proxy.base.clone(),
|
||||
server: store::load_server(&app)?,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn set_server(app: AppHandle, state: State<'_, AppState>, url: String) -> Result<String, String> {
|
||||
let server = proxy::validate_server(&url)?;
|
||||
state.proxy.check(&server).await?;
|
||||
store::save_server(&app, Some(&server))?;
|
||||
*state.proxy.server.write().map_err(|e| e.to_string())? = Some(server.clone());
|
||||
*state.server.lock().map_err(|e| e.to_string())? = Some(server.clone());
|
||||
Ok(server)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn clear_server(app: AppHandle, state: State<AppState>) -> Result<(), String> {
|
||||
store::save_server(&app, None)?;
|
||||
store::save_user(&app, None)?;
|
||||
store::save_refresh(None)?;
|
||||
*state.proxy.server.write().map_err(|e| e.to_string())? = None;
|
||||
*state.server.lock().map_err(|e| e.to_string())? = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn token_get() -> Result<Option<String>, String> {
|
||||
store::load_refresh()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn token_set(token: String) -> Result<(), String> {
|
||||
store::save_refresh(Some(&token))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn token_clear() -> Result<(), String> {
|
||||
store::save_refresh(None)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn user_get(app: AppHandle) -> Result<Option<String>, String> {
|
||||
store::load_user(&app)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn user_set(app: AppHandle, json: String) -> Result<(), String> {
|
||||
store::save_user(&app, Some(&json))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn user_clear(app: AppHandle) -> Result<(), String> {
|
||||
store::save_user(&app, None)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn media_set(
|
||||
state: State<AppState>,
|
||||
title: String,
|
||||
artist: String,
|
||||
album: String,
|
||||
cover_url: Option<String>,
|
||||
playing: bool,
|
||||
) -> Result<(), String> {
|
||||
state.media.set(&title, &artist, &album, cover_url.as_deref(), playing)
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _, _| show_main(app)))
|
||||
.plugin(tauri_plugin_window_state::Builder::default().build())
|
||||
.setup(|app| {
|
||||
let saved = store::load_server(app.handle())?;
|
||||
let proxy = tauri::async_runtime::block_on(Proxy::start(saved.clone())).map_err(|e| e.to_string())?;
|
||||
let media = Media::new();
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = media.attach(app.handle().clone(), &window);
|
||||
}
|
||||
app.manage(AppState {
|
||||
proxy,
|
||||
media,
|
||||
server: Mutex::new(saved),
|
||||
});
|
||||
|
||||
let open = MenuItem::with_id(app, "open", "Открыть", true, None::<&str>)?;
|
||||
let quit = MenuItem::with_id(app, "quit", "Выход", true, None::<&str>)?;
|
||||
let menu = Menu::with_items(app, &[&open, &quit])?;
|
||||
let icon: Image = include_image!("icons/32x32.png");
|
||||
TrayIconBuilder::new()
|
||||
.icon(icon)
|
||||
.menu(&menu)
|
||||
.show_menu_on_left_click(false)
|
||||
.on_menu_event(|app, event| match event.id.as_ref() {
|
||||
"open" => show_main(app),
|
||||
"quit" => app.exit(0),
|
||||
_ => {}
|
||||
})
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
if let TrayIconEvent::Click {
|
||||
button: MouseButton::Left,
|
||||
button_state: MouseButtonState::Up,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
show_main(tray.app_handle());
|
||||
}
|
||||
})
|
||||
.build(app)?;
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
bootstrap, set_server, clear_server, token_get, token_set, token_clear, user_get, user_set, user_clear, media_set
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("desktop app failed to start");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
pipu_desktop_lib::run();
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
use souvlaki::{MediaControlEvent, MediaControls, MediaMetadata, MediaPlayback, PlatformConfig};
|
||||
use std::sync::Mutex;
|
||||
use tauri::{AppHandle, Emitter, WebviewWindow};
|
||||
|
||||
pub struct Media {
|
||||
controls: Mutex<Option<MediaControls>>,
|
||||
}
|
||||
|
||||
impl Media {
|
||||
pub fn new() -> Self {
|
||||
Self { controls: Mutex::new(None) }
|
||||
}
|
||||
|
||||
pub fn attach(&self, app: AppHandle, window: &WebviewWindow) -> Result<(), String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
let hwnd = window.hwnd().ok().map(|h| h.0 as *mut std::ffi::c_void);
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let hwnd = {
|
||||
let _ = window;
|
||||
None
|
||||
};
|
||||
let mut controls = MediaControls::new(PlatformConfig {
|
||||
dbus_name: "app.pipu.music.desktop",
|
||||
display_name: "Pipu Music",
|
||||
hwnd,
|
||||
})
|
||||
.map_err(|e| format!("{e:?}"))?;
|
||||
let handle = app.clone();
|
||||
controls
|
||||
.attach(move |event| {
|
||||
let name = match event {
|
||||
MediaControlEvent::Play => "play",
|
||||
MediaControlEvent::Pause => "pause",
|
||||
MediaControlEvent::Toggle => "toggle",
|
||||
MediaControlEvent::Next => "next",
|
||||
MediaControlEvent::Previous => "previous",
|
||||
MediaControlEvent::Stop => "stop",
|
||||
_ => return,
|
||||
};
|
||||
let _ = handle.emit("media", name);
|
||||
})
|
||||
.map_err(|e| format!("{e:?}"))?;
|
||||
*self.controls.lock().map_err(|e| e.to_string())? = Some(controls);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set(&self, title: &str, artist: &str, album: &str, cover_url: Option<&str>, playing: bool) -> Result<(), String> {
|
||||
let mut slot = self.controls.lock().map_err(|e| e.to_string())?;
|
||||
let Some(controls) = slot.as_mut() else { return Ok(()); };
|
||||
controls
|
||||
.set_metadata(MediaMetadata {
|
||||
title: Some(title),
|
||||
artist: Some(artist),
|
||||
album: Some(album),
|
||||
cover_url,
|
||||
duration: None,
|
||||
})
|
||||
.map_err(|e| format!("{e:?}"))?;
|
||||
let playback = if playing { MediaPlayback::Playing { progress: None } } else { MediaPlayback::Paused { progress: None } };
|
||||
controls.set_playback(playback).map_err(|e| format!("{e:?}"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
//! Loopback-only streaming transport. The random per-process path is a capability;
|
||||
//! it never leaves the app except for OS media artwork requests. No open proxy.
|
||||
use axum::{body::Body, extract::{Request, State}, http::{HeaderMap, HeaderValue, Method, StatusCode}, response::{IntoResponse, Response}, Router};
|
||||
use serde_json::Value;
|
||||
use std::{sync::{Arc, RwLock}, time::Duration};
|
||||
use url::Url;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Proxy {
|
||||
pub server: Arc<RwLock<Option<String>>>,
|
||||
pub base: String,
|
||||
secret: String,
|
||||
authority: String,
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
pub fn validate_server(raw: &str) -> Result<String, String> {
|
||||
let url = Url::parse(raw.trim()).map_err(|_| "Invalid server URL")?;
|
||||
if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none()
|
||||
|| !url.username().is_empty() || url.password().is_some()
|
||||
|| url.query().is_some() || url.fragment().is_some()
|
||||
{ return Err("Use an HTTP(S) server address without credentials, query or fragment".into()); }
|
||||
Ok(url.as_str().trim_end_matches('/').to_string())
|
||||
}
|
||||
|
||||
impl Proxy {
|
||||
pub async fn start(server: Option<String>) -> Result<Self, String> {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.map_err(|e| e.to_string())?;
|
||||
let authority = listener.local_addr().map_err(|e| e.to_string())?.to_string();
|
||||
let secret = uuid::Uuid::new_v4().simple().to_string();
|
||||
let proxy = Self {
|
||||
server: Arc::new(RwLock::new(server)),
|
||||
base: format!("http://{authority}/{secret}"), secret, authority,
|
||||
client: reqwest::Client::builder()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.read_timeout(Duration::from_secs(60))
|
||||
.build().map_err(|e| e.to_string())?,
|
||||
};
|
||||
let router = Router::new().fallback(forward).with_state(proxy.clone());
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Err(e) = axum::serve(listener, router).await { eprintln!("Desktop transport stopped: {e}"); }
|
||||
});
|
||||
Ok(proxy)
|
||||
}
|
||||
|
||||
pub async fn check(&self, server: &str) -> Result<(), String> {
|
||||
let response = self.client.get(format!("{server}/api/v1/server-info"))
|
||||
.timeout(Duration::from_secs(10)).send().await.map_err(|_| "Server unavailable")?;
|
||||
if !response.status().is_success() { return Err("Server unavailable".into()); }
|
||||
let info: Value = response.json().await.map_err(|_| "Not a Pipu server")?;
|
||||
if info.get("api_version").is_none() || info.get("name").is_none() { return Err("Not a Pipu server".into()); }
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn allowed_origin(origin: &str) -> bool {
|
||||
matches!(origin, "tauri://localhost" | "http://tauri.localhost")
|
||||
|| (cfg!(debug_assertions) && origin == "http://127.0.0.1:5173")
|
||||
}
|
||||
|
||||
// Signed media and tus URLs are relative in the API. Rewrite only API URLs from
|
||||
// the configured server; never route arbitrary URLs supplied in server content.
|
||||
fn local_url(value: &str, server: &str, base: &str) -> Option<String> {
|
||||
if value.starts_with("/api/") { return Some(format!("{base}{value}")); }
|
||||
value.strip_prefix(server).filter(|p| p.starts_with("/api/"))
|
||||
.map(|p| format!("{base}{p}"))
|
||||
}
|
||||
|
||||
fn rewrite_json(value: &mut Value, server: &str, base: &str) {
|
||||
match value {
|
||||
Value::Array(items) => items.iter_mut().for_each(|v| rewrite_json(v, server, base)),
|
||||
Value::Object(fields) => for (key, v) in fields {
|
||||
if matches!(key.as_str(), "stream_url" | "cover_url" | "tus_url" | "download_url") {
|
||||
if let Some(url) = v.as_str().and_then(|s| local_url(s, server, base)) { *v = Value::String(url); }
|
||||
} else { rewrite_json(v, server, base); }
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn cors(headers: &mut HeaderMap, origin: Option<&str>) {
|
||||
if let Some(origin) = origin {
|
||||
headers.insert("access-control-allow-origin", HeaderValue::from_str(origin).unwrap());
|
||||
headers.insert("vary", HeaderValue::from_static("Origin"));
|
||||
}
|
||||
headers.insert("access-control-allow-methods", HeaderValue::from_static("GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS"));
|
||||
headers.insert("access-control-allow-headers", HeaderValue::from_static("authorization, content-type, range, tus-resumable, upload-length, upload-offset, upload-metadata, upload-defer-length, x-requested-with"));
|
||||
headers.insert("access-control-expose-headers", HeaderValue::from_static("content-range, accept-ranges, location, upload-offset, upload-length, tus-resumable, retry-after"));
|
||||
}
|
||||
|
||||
async fn forward(State(proxy): State<Proxy>, request: Request) -> Response {
|
||||
let origin = request.headers().get("origin").and_then(|v| v.to_str().ok()).map(str::to_owned);
|
||||
if request.headers().get("host").and_then(|v| v.to_str().ok()) != Some(&proxy.authority)
|
||||
|| origin.as_deref().is_some_and(|s| !allowed_origin(s))
|
||||
{ return StatusCode::FORBIDDEN.into_response(); }
|
||||
let prefix = format!("/{}/api/", proxy.secret);
|
||||
let path = request.uri().path().to_owned();
|
||||
let Some(tail) = path.strip_prefix(&prefix) else { return StatusCode::NOT_FOUND.into_response(); };
|
||||
let tail = tail.to_owned();
|
||||
// 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).await {
|
||||
Ok(response) => response,
|
||||
Err(_) => (StatusCode::BAD_GATEWAY, "Server unavailable").into_response(),
|
||||
}
|
||||
};
|
||||
cors(response.headers_mut(), origin.as_deref());
|
||||
response
|
||||
}
|
||||
|
||||
async fn transfer(proxy: &Proxy, request: Request, tail: String) -> Result<Response, String> {
|
||||
let server = proxy.server.read().map_err(|_| "State unavailable")?.clone().ok_or("No server selected")?;
|
||||
let query = request.uri().query().map(|q| format!("?{q}")).unwrap_or_default();
|
||||
let url = format!("{server}/api/{tail}{query}");
|
||||
let (parts, body) = request.into_parts();
|
||||
let mut upstream = proxy.client.request(parts.method.clone(), url);
|
||||
for name in ["authorization", "content-type", "content-length", "range", "if-range", "if-none-match", "tus-resumable", "upload-length", "upload-offset", "upload-metadata", "upload-defer-length"] {
|
||||
if let Some(value) = parts.headers.get(name) { upstream = upstream.header(name, value); }
|
||||
}
|
||||
let upstream = upstream.body(reqwest::Body::wrap_stream(body.into_data_stream())).send().await.map_err(|e| e.to_string())?;
|
||||
let status = upstream.status();
|
||||
let mut headers = HeaderMap::new();
|
||||
for name in ["content-type", "content-length", "content-range", "accept-ranges", "etag", "last-modified", "cache-control", "upload-offset", "upload-length", "tus-resumable", "retry-after", "content-disposition"] {
|
||||
if let Some(value) = upstream.headers().get(name) { headers.insert(name, value.clone()); }
|
||||
}
|
||||
if let Some(location) = upstream.headers().get("location").and_then(|v| v.to_str().ok()) {
|
||||
let local = local_url(location, &server, &proxy.base).ok_or("External redirect refused")?;
|
||||
headers.insert("location", HeaderValue::from_str(&local).map_err(|e| e.to_string())?);
|
||||
}
|
||||
let json = headers.get("content-type").and_then(|v| v.to_str().ok())
|
||||
.is_some_and(|v| v.contains("application/json") || v.contains("application/problem+json"));
|
||||
let body = if json && parts.method != Method::HEAD {
|
||||
// Bound only JSON, never buffer a song or an upload in memory.
|
||||
let bytes = axum::body::to_bytes(Body::from_stream(upstream.bytes_stream()), 16 * 1024 * 1024).await.map_err(|e| e.to_string())?;
|
||||
let mut value: Value = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?;
|
||||
rewrite_json(&mut value, &server, &proxy.base);
|
||||
headers.remove("content-length");
|
||||
headers.remove("etag");
|
||||
Body::from(serde_json::to_vec(&value).map_err(|e| e.to_string())?)
|
||||
} else { Body::from_stream(upstream.bytes_stream()) };
|
||||
let mut response = Response::new(body);
|
||||
*response.status_mut() = status;
|
||||
*response.headers_mut() = headers;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn validates_server_and_scopes_media_urls() {
|
||||
assert_eq!(validate_server("https://music.example/pipu/").unwrap(), "https://music.example/pipu");
|
||||
for url in ["file:///etc/passwd", "https://user:pass@host", "https://host/?token=x", "https://host/#x"] { assert!(validate_server(url).is_err()); }
|
||||
assert_eq!(local_url("https://music.example.evil/api/a", "https://music.example", "http://local"), None);
|
||||
let mut value = serde_json::json!({"tracks": [{"stream_url": "/api/v1/stream/a?sig=b", "title": "/api/private", "album": {"cover_url": "/api/v1/covers/a"}}]});
|
||||
rewrite_json(&mut value, "https://music.example", "http://local/key");
|
||||
assert_eq!(value["tracks"][0]["stream_url"], "http://local/key/api/v1/stream/a?sig=b");
|
||||
assert_eq!(value["tracks"][0]["title"], "/api/private");
|
||||
assert_eq!(value["tracks"][0]["album"]["cover_url"], "http://local/key/api/v1/covers/a");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
const SERVICE: &str = "app.pipu.music.desktop";
|
||||
|
||||
fn config_dir(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
let dir = app.path().app_config_dir().map_err(|e| e.to_string())?;
|
||||
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
pub fn load_server(app: &AppHandle) -> Result<Option<String>, String> {
|
||||
let path = config_dir(app)?.join("server.txt");
|
||||
match fs::read_to_string(path) {
|
||||
Ok(s) => {
|
||||
let s = s.trim().to_string();
|
||||
Ok((!s.is_empty()).then_some(s))
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_server(app: &AppHandle, server: Option<&str>) -> Result<(), String> {
|
||||
let path = config_dir(app)?.join("server.txt");
|
||||
match server {
|
||||
Some(s) => fs::write(path, s).map_err(|e| e.to_string()),
|
||||
None => match fs::remove_file(path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(e.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_entry() -> Result<keyring::Entry, String> {
|
||||
keyring::Entry::new(SERVICE, "refresh").map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn load_refresh() -> Result<Option<String>, String> {
|
||||
match refresh_entry()?.get_password() {
|
||||
Ok(s) => Ok(Some(s)),
|
||||
Err(keyring::Error::NoEntry) => Ok(None),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_refresh(token: Option<&str>) -> Result<(), String> {
|
||||
let entry = refresh_entry()?;
|
||||
match token {
|
||||
Some(t) => entry.set_password(t).map_err(|e| e.to_string()),
|
||||
None => match entry.delete_credential() {
|
||||
Ok(()) => Ok(()),
|
||||
Err(keyring::Error::NoEntry) => Ok(()),
|
||||
Err(e) => Err(e.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_user(app: &AppHandle) -> Result<Option<String>, String> {
|
||||
let path = config_dir(app)?.join("user.json");
|
||||
match fs::read_to_string(path) {
|
||||
Ok(s) => Ok(Some(s)),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_user(app: &AppHandle, json: Option<&str>) -> Result<(), String> {
|
||||
let path = config_dir(app)?.join("user.json");
|
||||
match json {
|
||||
Some(s) => fs::write(path, s).map_err(|e| e.to_string()),
|
||||
None => match fs::remove_file(path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(e.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Pipu Music",
|
||||
"version": "0.0.0",
|
||||
"identifier": "app.pipu.music.desktop",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm --filter @pipu/web dev",
|
||||
"devUrl": "http://127.0.0.1:5173",
|
||||
"beforeBuildCommand": "pnpm --filter @pipu/web build",
|
||||
"frontendDist": "../../web/dist"
|
||||
},
|
||||
"app": {
|
||||
"withGlobalTauri": true,
|
||||
"windows": [{ "label": "main", "title": "Pipu Music", "width": 1200, "height": 800, "minWidth": 720, "minHeight": 540 }],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http://127.0.0.1:*; font-src 'self' data:; media-src 'self' blob: http://127.0.0.1:*; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:*; object-src 'none'; base-uri 'self'; frame-src 'none'"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["nsis", "appimage", "deb", "rpm"],
|
||||
"category": "Music",
|
||||
"shortDescription": "Self-hosted music player",
|
||||
"icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.ico", "icons/icon.icns"],
|
||||
"linux": {
|
||||
"deb": { "depends": ["libwebkit2gtk-4.1-0", "libayatana-appindicator3-1", "libsecret-1-0", "gstreamer1.0-plugins-good", "gstreamer1.0-plugins-bad", "gstreamer1.0-libav"] },
|
||||
"appimage": { "bundleMediaFramework": true }
|
||||
},
|
||||
"windows": { "nsis": { "installMode": "currentUser", "languages": ["English", "Russian"], "displayLanguageSelector": true } }
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,9 @@ const MyUploadsPage = lazy(() => import('../pages/uploads/MyUploadsPage').then((
|
||||
const SuggestPage = lazy(() => import('../pages/uploads/SuggestPage').then((m) => ({ default: m.SuggestPage })));
|
||||
const 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;
|
||||
}
|
||||
|
||||
@@ -63,8 +69,26 @@ const rootRoute = createRootRouteWithContext<RouterContext>()({
|
||||
errorComponent: RouteError,
|
||||
});
|
||||
|
||||
// Публичная: ссылку на приложение отправляют тем, у кого ещё нет аккаунта на этом устройстве.
|
||||
const androidAppRoute = createRoute({ getParentRoute: () => rootRoute, path: '/android', component: AppDownloadPage });
|
||||
// Публичная: ссылку на приложения отправляют тем, у кого ещё нет клиента на устройстве.
|
||||
const downloadAppRoute = createRoute({ getParentRoute: () => rootRoute, path: '/download', component: AppDownloadPage });
|
||||
const androidAppRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/android',
|
||||
beforeLoad: () => {
|
||||
throw redirect({ to: '/download' });
|
||||
},
|
||||
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,
|
||||
@@ -72,6 +96,7 @@ const loginRoute = createRoute({
|
||||
validateSearch: (search: Record<string, unknown>): { 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 +110,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: () => <SetupPage variant="admin" />,
|
||||
@@ -95,7 +120,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: () => <SetupPage variant="password" />,
|
||||
@@ -106,7 +131,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 };
|
||||
@@ -245,7 +270,9 @@ const adminSettingsRoute = createRoute({
|
||||
});
|
||||
|
||||
const routeTree = rootRoute.addChildren([
|
||||
downloadAppRoute,
|
||||
androidAppRoute,
|
||||
connectRoute,
|
||||
loginRoute,
|
||||
setupAdminRoute,
|
||||
setupPasswordRoute,
|
||||
@@ -273,8 +300,12 @@ const routeTree = rootRoute.addChildren([
|
||||
]),
|
||||
]);
|
||||
|
||||
export function createAppRouter(session: AuthSession, opts: { history?: Parameters<typeof createRouter>[0]['history'] } = {}) {
|
||||
const router = createRouter({ routeTree, context: { session }, defaultPreload: false, ...opts });
|
||||
export function createAppRouter(
|
||||
session: AuthSession,
|
||||
opts: { history?: Parameters<typeof createRouter>[0]['history']; desktop?: DesktopHandle | null } = {},
|
||||
) {
|
||||
const { desktop = null, ...rest } = opts;
|
||||
const router = createRouter({ routeTree, context: { session, desktop }, defaultPreload: false, ...rest });
|
||||
// Выход, блокировка, отзыв сессии — guards пересчитываются, и пользователь уходит на /login.
|
||||
session.subscribe(() => void router.invalidate());
|
||||
return router;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createDesktop, isDesktop } from './desktop';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('desktop bridge', () => {
|
||||
it('is inactive in the browser', () => {
|
||||
expect(isDesktop()).toBe(false);
|
||||
});
|
||||
|
||||
it('bootstraps through the Tauri invoke surface', async () => {
|
||||
const invoke = vi.fn(async (cmd: string) => {
|
||||
if (cmd === 'bootstrap') return { proxyBase: 'http://127.0.0.1:9/abc', server: 'https://music.example' };
|
||||
if (cmd === 'set_server') return 'https://music.example/pipu';
|
||||
return null;
|
||||
});
|
||||
(window as Window & { __TAURI__?: unknown }).__TAURI__ = { core: { invoke }, event: { listen: vi.fn() } };
|
||||
const desktop = await createDesktop();
|
||||
expect(desktop?.proxyBase).toBe('http://127.0.0.1:9/abc');
|
||||
expect(desktop?.server).toBe('https://music.example');
|
||||
await desktop?.setServer('https://music.example/pipu');
|
||||
expect(invoke).toHaveBeenCalledWith('set_server', { url: 'https://music.example/pipu' });
|
||||
delete (window as Window & { __TAURI__?: unknown }).__TAURI__;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { AuthUser, RefreshStore } from '@pipu/core';
|
||||
import { createContext, createElement, useContext, type ReactNode } from 'react';
|
||||
|
||||
type Tauri = {
|
||||
core: { invoke: (cmd: string, args?: Record<string, unknown>) => Promise<unknown> };
|
||||
event: { listen: (event: string, handler: (e: { payload: string }) => void) => Promise<() => void> };
|
||||
};
|
||||
|
||||
function api(): Tauri | undefined {
|
||||
return (window as Window & { __TAURI__?: Tauri }).__TAURI__;
|
||||
}
|
||||
|
||||
export function isDesktop(): boolean {
|
||||
return api() !== undefined;
|
||||
}
|
||||
|
||||
function invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
|
||||
const tauri = api();
|
||||
if (!tauri) throw new Error('Tauri API unavailable');
|
||||
return tauri.core.invoke(cmd, args) as Promise<T>;
|
||||
}
|
||||
|
||||
export type DesktopHandle = {
|
||||
proxyBase: string;
|
||||
server: string | null;
|
||||
setServer: (url: string) => Promise<string>;
|
||||
clearServer: () => Promise<void>;
|
||||
refreshStore: RefreshStore;
|
||||
listenMedia: (handler: (action: string) => void) => Promise<() => void>;
|
||||
setMedia: (info: { title: string; artist: string; album: string; coverUrl?: string | null; playing: boolean }) => void;
|
||||
};
|
||||
|
||||
export const DesktopContext = createContext<DesktopHandle | null>(null);
|
||||
|
||||
export function DesktopProvider({ value, children }: { value: DesktopHandle | null; children: ReactNode }) {
|
||||
return createElement(DesktopContext.Provider, { value }, children);
|
||||
}
|
||||
|
||||
export function useDesktop(): DesktopHandle | null {
|
||||
return useContext(DesktopContext);
|
||||
}
|
||||
|
||||
let active: DesktopHandle | null = null;
|
||||
|
||||
export function desktopHandle(): DesktopHandle | null {
|
||||
return active;
|
||||
}
|
||||
|
||||
export async function createDesktop(): Promise<DesktopHandle | null> {
|
||||
if (!isDesktop()) return null;
|
||||
const boot = await invoke<{ proxyBase: string; server: string | null }>('bootstrap');
|
||||
const handle: DesktopHandle = {
|
||||
proxyBase: boot.proxyBase,
|
||||
server: boot.server,
|
||||
setServer: async (url) => {
|
||||
const server = await invoke<string>('set_server', { url });
|
||||
handle.server = server;
|
||||
return server;
|
||||
},
|
||||
clearServer: async () => {
|
||||
await invoke('clear_server');
|
||||
handle.server = null;
|
||||
},
|
||||
refreshStore: {
|
||||
get: () => invoke<string | null>('token_get'),
|
||||
set: (token) => invoke('token_set', { token }),
|
||||
clear: async () => {
|
||||
await invoke('token_clear');
|
||||
await invoke('user_clear');
|
||||
},
|
||||
getUser: async () => {
|
||||
const json = await invoke<string | null>('user_get');
|
||||
return json ? (JSON.parse(json) as AuthUser) : null;
|
||||
},
|
||||
setUser: (user) => invoke('user_set', { json: JSON.stringify(user) }),
|
||||
},
|
||||
listenMedia: async (handler) => {
|
||||
const tauri = api();
|
||||
if (!tauri) return () => undefined;
|
||||
return tauri.event.listen('media', (e) => handler(e.payload));
|
||||
},
|
||||
setMedia: (info) => {
|
||||
void invoke('media_set', {
|
||||
title: info.title,
|
||||
artist: info.artist,
|
||||
album: info.album,
|
||||
coverUrl: info.coverUrl ?? null,
|
||||
playing: info.playing,
|
||||
});
|
||||
},
|
||||
};
|
||||
active = handle;
|
||||
return handle;
|
||||
}
|
||||
+12
-3
@@ -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(
|
||||
<StrictMode>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<App session={session} router={router} />
|
||||
<DesktopProvider value={desktop}>
|
||||
<App session={session} router={router} />
|
||||
</DesktopProvider>
|
||||
</I18nextProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -1,25 +1,27 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useSession } from '../app/session';
|
||||
import { formatBytes } from '../shared/lib/format';
|
||||
import { detectAppClient, otherAppClients, type AppClient } from '../shared/lib/platform';
|
||||
import { checkIcon, copyIcon, downloadIcon } from '../shared/ui/icons';
|
||||
import { Icon } from '../shared/ui/ui';
|
||||
import { AuthLayout } from './AuthLayout';
|
||||
import s from './auth.module.css';
|
||||
|
||||
/**
|
||||
* Публичная страница Android-приложения (/android): ссылку можно просто отправить человеку.
|
||||
* APK — последний релиз, который сервер сам берёт из Gitea (docs/mobile.md#самообновление).
|
||||
* Публичная страница приложений (/download, /android): ссылку можно отправить человеку.
|
||||
* Сборки — последний релиз Gitea через сервер; первым предлагается клиент текущей ОС.
|
||||
*/
|
||||
export function AppDownloadPage() {
|
||||
const { t, i18n } = useTranslation(['auth', 'common']);
|
||||
const session = useSession();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const detected = useMemo(() => detectAppClient(), []);
|
||||
const latest = useQuery({
|
||||
queryKey: ['android-latest'],
|
||||
queryFn: async () => (await session.client.GET('/api/v1/app/android/latest')).data ?? null,
|
||||
queryKey: ['app-clients'],
|
||||
queryFn: async () => (await session.client.GET('/api/v1/app/clients')).data ?? null,
|
||||
staleTime: 10 * 60_000,
|
||||
});
|
||||
const server = location.origin;
|
||||
@@ -28,39 +30,59 @@ export function AppDownloadPage() {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
});
|
||||
const r = latest.data;
|
||||
const data = latest.data;
|
||||
const files = Object.fromEntries((data?.clients ?? []).map((c) => [c.client, c]));
|
||||
const primary = detected ? files[detected] : undefined;
|
||||
const others = otherAppClients(detected).filter((id) => files[id]);
|
||||
|
||||
return (
|
||||
<AuthLayout footer={<a href="/login">{t('app.to_login')}</a>}>
|
||||
<span className={s.badge}>Android</span>
|
||||
<span className={s.badge}>{detected ? t(`app.platform.${detected}`) : t('app.badge')}</span>
|
||||
<h1>{t('app.title')}</h1>
|
||||
<p className={s.lead}>{t('app.subtitle')}</p>
|
||||
{latest.isPending ? null : r ? (
|
||||
{latest.isPending ? null : data ? (
|
||||
<>
|
||||
<a className={s.download} href={r.download_url} download>
|
||||
<Icon src={downloadIcon} size={22} />
|
||||
<span>
|
||||
{t('app.download')}
|
||||
<span className={s.downloadMeta}>
|
||||
{t('app.version', { version: r.version })} · {formatBytes(r.size, i18n.language)}
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
<ol className={s.steps}>
|
||||
<li>{t('app.step_install')}</li>
|
||||
<li>
|
||||
{t('app.step_server')}
|
||||
<button type="button" className={s.server} onClick={copy} aria-label={t('app.copy_server')}>
|
||||
<code>{server}</code>
|
||||
<Icon src={copied ? checkIcon : copyIcon} size={16} />
|
||||
</button>
|
||||
</li>
|
||||
<li>{t('app.step_updates')}</li>
|
||||
</ol>
|
||||
<details className={s.details}>
|
||||
<summary>{t('app.checksum')}</summary>
|
||||
<code>{r.sha256}</code>
|
||||
</details>
|
||||
{primary ? (
|
||||
<DownloadButton client={detected!} href={primary.download_url} meta={`${t('app.version', { version: data.version })} · ${formatBytes(primary.size, i18n.language)}`} />
|
||||
) : detected ? (
|
||||
<div className={s.hint}>{t('app.unavailable_platform', { platform: t(`app.platform.${detected}`) })}</div>
|
||||
) : null}
|
||||
{(primary || others.length > 0) && (
|
||||
<ol className={s.steps}>
|
||||
{detected && <li>{t(`app.step_install.${detected}`)}</li>}
|
||||
<li>
|
||||
{t('app.step_server')}
|
||||
<button type="button" className={s.server} onClick={copy} aria-label={t('app.copy_server')}>
|
||||
<code>{server}</code>
|
||||
<Icon src={copied ? checkIcon : copyIcon} size={16} />
|
||||
</button>
|
||||
</li>
|
||||
{detected && <li>{t(`app.step_updates.${detected}`)}</li>}
|
||||
</ol>
|
||||
)}
|
||||
{primary?.sha256 && (
|
||||
<details className={s.details}>
|
||||
<summary>{t('app.checksum')}</summary>
|
||||
<code>{primary.sha256}</code>
|
||||
</details>
|
||||
)}
|
||||
{others.length > 0 && (
|
||||
<div className={s.otherClients}>
|
||||
<p className={s.otherTitle}>{t('app.other')}</p>
|
||||
{others.map((id) => {
|
||||
const file = files[id]!;
|
||||
return (
|
||||
<a key={id} className={s.downloadAlt} href={file.download_url} download={file.filename}>
|
||||
{t(`app.download.${id}`)}
|
||||
<span>
|
||||
{t('app.version', { version: data.version })} · {formatBytes(file.size, i18n.language)}
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{!primary && others.length === 0 && <div className={s.hint}>{t('app.unavailable')}</div>}
|
||||
</>
|
||||
) : (
|
||||
<div className={s.hint}>{t('app.unavailable')}</div>
|
||||
@@ -68,3 +90,16 @@ export function AppDownloadPage() {
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function DownloadButton({ client, href, meta }: { client: AppClient; href: string; meta: string }) {
|
||||
const { t } = useTranslation('auth');
|
||||
return (
|
||||
<a className={s.download} href={href} download>
|
||||
<Icon src={downloadIcon} size={22} />
|
||||
<span>
|
||||
{t(`app.download.${client}`)}
|
||||
<span className={s.downloadMeta}>{meta}</span>
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useRouter } from '@tanstack/react-router';
|
||||
|
||||
import { useDesktop } from '../desktop';
|
||||
import { Button, Field, FormError } from '../shared/ui/ui';
|
||||
import { AuthLayout } from './AuthLayout';
|
||||
import s from './auth.module.css';
|
||||
|
||||
export function ConnectPage() {
|
||||
const { t } = useTranslation();
|
||||
const desktop = useDesktop();
|
||||
const router = useRouter();
|
||||
const [address, setAddress] = useState(desktop?.server ?? '');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!desktop) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await desktop.setServer(address);
|
||||
await router.invalidate();
|
||||
await router.navigate({ to: '/login' });
|
||||
} catch (err) {
|
||||
const text = err instanceof Error ? err.message : '';
|
||||
setError(/Invalid|query|fragment|credentials/i.test(text) ? t('server.invalid_address') : t('server.offline'));
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<h1>{t('server.title')}</h1>
|
||||
<p className={s.lead}>{t('app.tagline')}</p>
|
||||
<form onSubmit={(e) => void submit(e)} noValidate>
|
||||
<Field
|
||||
label={t('server.address')}
|
||||
value={address}
|
||||
onChange={setAddress}
|
||||
autoComplete="url"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
required
|
||||
/>
|
||||
<FormError>{error}</FormError>
|
||||
<Button type="submit" variant="primary" className={s.submit} busy={busy} disabled={!address.trim()}>
|
||||
{t('mobile.connect')}
|
||||
</Button>
|
||||
</form>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { useState, type FormEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { 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} · <a href="/android">{t('app.link')}</a>
|
||||
pipu {info.data.version}
|
||||
{desktop ? null : (
|
||||
<>
|
||||
{' · '}
|
||||
<a href="/download">{t('app.link')}</a>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
<FormError>{sessions.error || revoke.error ? errorText(sessions.error ?? revoke.error) : null}</FormError>
|
||||
<div>
|
||||
{desktop && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
await desktop.clearServer();
|
||||
await session.logout();
|
||||
await navigate({ to: '/connect' });
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{t('mobile.change_server')}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="danger" onClick={() => void session.logout(true)}>
|
||||
{t('settings.logout_all')}
|
||||
</Button>
|
||||
|
||||
@@ -165,7 +165,7 @@
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
/* Страница Android-приложения (/android). */
|
||||
/* Страница приложений (/download). */
|
||||
.download {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -238,6 +238,40 @@
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.otherClients {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.otherTitle {
|
||||
margin: 8px 0 0;
|
||||
color: var(--text3);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.downloadAlt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--line2);
|
||||
background: var(--surface2);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.downloadAlt span {
|
||||
color: var(--text3);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.link {
|
||||
align-self: center;
|
||||
}
|
||||
@@ -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<PlayerState>) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { detectAppClient, otherAppClients } from './platform';
|
||||
|
||||
describe('detectAppClient', () => {
|
||||
it('picks the native client from the user agent', () => {
|
||||
expect(detectAppClient('Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36')).toBe('android');
|
||||
expect(detectAppClient('Mozilla/5.0 (Windows NT 10.0; Win64; x64)')).toBe('windows');
|
||||
expect(detectAppClient('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)')).toBe('macos');
|
||||
expect(detectAppClient('Mozilla/5.0 (X11; Linux x86_64)')).toBe('linux');
|
||||
expect(detectAppClient('Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)')).toBeNull();
|
||||
});
|
||||
|
||||
it('lists the remaining platforms after the detected one', () => {
|
||||
expect(otherAppClients('windows')).toEqual(['macos', 'linux', 'android']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
export type AppClient = 'android' | 'windows' | 'linux' | 'macos';
|
||||
|
||||
const ORDER: AppClient[] = ['windows', 'macos', 'linux', 'android'];
|
||||
|
||||
/** Клиент, который стоит предложить первым по User-Agent. На iOS приложения нет. */
|
||||
export function detectAppClient(ua = typeof navigator === 'undefined' ? '' : navigator.userAgent): AppClient | null {
|
||||
if (/Android/i.test(ua)) return 'android';
|
||||
if (/iPhone|iPad|iPod/i.test(ua)) return null;
|
||||
if (/Windows/i.test(ua)) return 'windows';
|
||||
if (/Mac OS X|Macintosh/i.test(ua)) return 'macos';
|
||||
if (/Linux|X11/i.test(ua)) return 'linux';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function otherAppClients(primary: AppClient | null): AppClient[] {
|
||||
return ORDER.filter((c) => c !== primary);
|
||||
}
|
||||
@@ -116,6 +116,28 @@ export function fakeApi(initial: FakeUser[]) {
|
||||
min_android_version_code: 0,
|
||||
bootstrap_required: [...users.values()].some((u) => u.must_change_credentials),
|
||||
});
|
||||
case 'GET /api/v1/app/clients':
|
||||
return json({
|
||||
version: '1.3.0',
|
||||
version_code: 1_030_099,
|
||||
notes_md: '',
|
||||
clients: [
|
||||
{ client: 'windows', filename: 'pipu-music_1.3.0_x64-setup.exe', size: 20_000_000, download_url: '/api/v1/app/download/windows/1.3.0' },
|
||||
{ client: 'linux', filename: 'pipu-music_1.3.0_amd64.AppImage', size: 18_000_000, download_url: '/api/v1/app/download/linux/1.3.0' },
|
||||
{ client: 'macos', filename: 'pipu-music_1.3.0_aarch64.dmg', size: 22_000_000, download_url: '/api/v1/app/download/macos/1.3.0' },
|
||||
{ client: 'android', filename: 'pipu-music-1.3.0.apk', size: 12_000_000, sha256: 'ab'.repeat(32), download_url: '/api/v1/app/download/android/1.3.0' },
|
||||
],
|
||||
});
|
||||
case 'GET /api/v1/app/android/latest':
|
||||
return json({
|
||||
version: '1.3.0',
|
||||
version_code: 1_030_099,
|
||||
notes_md: '',
|
||||
size: 12_000_000,
|
||||
sha256: 'ab'.repeat(32),
|
||||
download_url: '/api/v1/app/android/download/1.3.0',
|
||||
min_supported: 0,
|
||||
});
|
||||
case 'POST /api/v1/auth/login': {
|
||||
const u = [...users.values()].find((x) => x.username === body?.username && x.password === body?.password);
|
||||
if (!u) return problem(401, 'auth.invalid_credentials');
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Suspense, useCallback, useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAuthState, useSession, useUser } from '../app/session';
|
||||
import { useDesktop } from '../desktop';
|
||||
import { useCollectionCounts } from '../entities/collection';
|
||||
import {
|
||||
moreIcon,
|
||||
@@ -25,7 +26,7 @@ import {
|
||||
dislikeFillIcon,
|
||||
dislikeIcon,
|
||||
settingsIcon,
|
||||
androidIcon,
|
||||
downloadIcon,
|
||||
chevronRightIcon,
|
||||
storageFillIcon,
|
||||
storageIcon, inboxFillIcon, inboxIcon, myUploadsFillIcon, myUploadsIcon, uploadFillIcon, uploadIcon, tuneFillIcon, tuneIcon} from '../shared/ui/icons';
|
||||
@@ -155,6 +156,7 @@ function Shell() {
|
||||
const { t } = useTranslation();
|
||||
const user = useUser();
|
||||
const session = useSession();
|
||||
const desktop = useDesktop();
|
||||
const navigate = useNavigate();
|
||||
const name = user.display_name ?? user.username;
|
||||
const staff = user.role !== 'user';
|
||||
@@ -252,7 +254,9 @@ function Shell() {
|
||||
}
|
||||
items={[
|
||||
{ label: t('profile.settings'), icon: settingsIcon, onSelect: () => void navigate({ to: '/settings' }) },
|
||||
{ label: t('profile.android_app'), icon: androidIcon, onSelect: () => void navigate({ to: '/android' }) },
|
||||
...(!desktop
|
||||
? [{ label: t('profile.download_apps'), icon: downloadIcon, onSelect: () => void navigate({ to: '/download' }) }]
|
||||
: []),
|
||||
{ label: t('profile.logout'), icon: logoutIcon, danger: true, onSelect: () => void session.logout() },
|
||||
]}
|
||||
/>
|
||||
|
||||
+2
-2
@@ -5,7 +5,7 @@
|
||||
## Суть
|
||||
|
||||
Закрытое (self-hosted) музыкальное приложение для прослушивания музыки с собственного сервера.
|
||||
Есть веб-версия и мобильное приложение для Android. Главная фишка — **«Моя волна»**: бесконечный умный поток
|
||||
Есть веб-версия, десктоп и мобильное приложение для Android. Главная фишка — **«Моя волна»**: бесконечный умный поток
|
||||
музыки, который подстраивается под вкус пользователя и под выбранное настроение или занятие.
|
||||
|
||||
Масштаб: до ~100 пользователей, библиотека от 11 000 треков и больше.
|
||||
@@ -16,7 +16,7 @@
|
||||
## Доступ и учётные записи
|
||||
|
||||
- При первом открытии приложение показывает **экран входа** (логин + пароль).
|
||||
В мобильном приложении на этом экране также указывается **адрес сервера**.
|
||||
В мобильном приложении и на десктопе на этом экране также указывается **адрес сервера**.
|
||||
- **Регистрации нет.** Учётные записи создаёт администрация.
|
||||
- Роли:
|
||||
- **Администратор** — полный доступ; единственный, кто может создавать и назначать модераторов
|
||||
|
||||
@@ -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 |
|
||||
|
||||
+3
-2
@@ -102,8 +102,9 @@
|
||||
прочитайте заметки к релизу на странице релизов Gitea.
|
||||
|
||||
Android-приложение обновляется само: при запуске оно сверяется с сервером и предлагает новый APK.
|
||||
Раздать приложение — ссылка `https://<ваш-домен>/android` (без входа): кнопка «Скачать APK» последнего релиза,
|
||||
адрес сервера для копирования и короткая инструкция; та же страница — внизу экрана входа и в меню профиля.
|
||||
Раздать приложение — ссылка `https://<ваш-домен>/download` (без входа; `/android` перенаправляет сюда):
|
||||
сборка под систему посетителя (APK, Windows, Linux, macOS), остальные платформы ниже, адрес сервера
|
||||
для копирования и короткая инструкция. Та же страница — внизу экрана входа и в меню профиля.
|
||||
|
||||
## 7. Наблюдение
|
||||
|
||||
|
||||
@@ -106,6 +106,8 @@
|
||||
### Мобильное обновление — [mobile.md](mobile.md#самообновление)
|
||||
| Метод | Путь | Описание |
|
||||
|---|---|---|
|
||||
| GET | `/app/clients` | `{version, version_code, notes_md, clients:[{client, filename, size, sha256?, download_url}]}` — Android, Windows, Linux, macOS |
|
||||
| GET | `/app/download/{client}/{version}` | файл клиента (прокси / кэш релиза Gitea) |
|
||||
| GET | `/app/android/latest` | `{version, version_code, notes_md, size, sha256, download_url, min_supported}` |
|
||||
| GET | `/app/android/download/{version}` | APK (прокси / кэш релиза Gitea) |
|
||||
|
||||
|
||||
@@ -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 клиент + типы
|
||||
|
||||
+1
-1
@@ -76,7 +76,7 @@
|
||||
| `BOOTSTRAP_ADMIN_PASSWORD` | пусто → сгенерировать и вывести в лог | |
|
||||
| `TRANSCODE_CACHE_LIMIT_GB` | `50` | |
|
||||
| `TRANSCODE_CONCURRENCY` | `2` | |
|
||||
| `GITEA_URL`, `GITEA_REPO`, `GITEA_TOKEN` | пусто — из `git remote` (`install.sh`, `update.sh`); токен — для закрытого репозитория | Самообновление Android и страница `/android` |
|
||||
| `GITEA_URL`, `GITEA_REPO`, `GITEA_TOKEN` | пусто — из `git remote` (`install.sh`, `update.sh`); токен — для закрытого репозитория | Самообновление Android и страница `/download` |
|
||||
| `PIPU_FILE_UID`, `PIPU_FILE_GID` | `1000` | Владелец файлов, которые агент записывает в принимающие хранилища |
|
||||
| `UPLOADS_DIR` | `/uploads` | Staging очереди на добавление (лимит очереди задаётся в админке) |
|
||||
| `LOG_LEVEL` | `info` | |
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# Десктоп (`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.
|
||||
|
||||
Готовые файлы с последнего релиза Gitea сервер отдаёт на публичной странице `/download`: посетителю сразу предлагается сборка его ОС, остальные платформы и Android — ниже.
|
||||
@@ -246,7 +246,7 @@ curl -s https://music.example.com/readyz
|
||||
и предлагает обновиться ([mobile.md](mobile.md#самообновление)). Адрес Gitea и репозиторий (`GITEA_URL`, `GITEA_REPO`)
|
||||
`install.sh` и `update.sh` берут из `git remote` клона; `GITEA_TOKEN` (только на чтение) нужен, если репозиторий
|
||||
закрытый. Если сервер не видит Gitea по внешнему адресу — впишите внутренний в `GITEA_URL`. Раздать приложение —
|
||||
ссылка `https://<ваш-домен>/android`.
|
||||
ссылка `https://<ваш-домен>/download`.
|
||||
|
||||
> Приложению нужен настоящий сертификат. С самоподписанным сертификатом и голым IP-адресом подключиться не получится.
|
||||
> Поэтому нужен домен с Let's Encrypt, как в этой инструкции.
|
||||
|
||||
+1
-1
@@ -99,7 +99,7 @@
|
||||
|
||||
## Самообновление
|
||||
|
||||
Первая установка — публичная страница `/android` веб-клиента: последний APK, адрес сервера и инструкция.
|
||||
Первая установка — публичная страница `/download` веб-клиента (`/android` ведёт туда же): APK, десктопные сборки по ОС, адрес сервера и инструкция.
|
||||
|
||||
Приложение распространяется только через Gitea Releases (не Google Play).
|
||||
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@
|
||||
```
|
||||
apps/web/src/
|
||||
├── app/ # провайдеры, роутер, глобальные стили, гарды
|
||||
├── pages/ # login, setup-admin, setup-password, home, search, artist, album,
|
||||
├── pages/ # login, setup-admin, setup-password, download, home, search, artist, album,
|
||||
│ # collection/{playlists,history,likes,dislikes}, playlist, track, settings,
|
||||
│ # admin/{users,sources,library,upload,upload-queue,settings}
|
||||
├── widgets/ # app-shell (топбар, сайдбар), player-bar, wave-button, search-box, track-list
|
||||
|
||||
@@ -1293,6 +1293,44 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/app/clients": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Клиенты последнего релиза (Android и десктоп)
|
||||
* @description Тот же релиз Gitea, что и для Android: APK, установщик Windows, AppImage/deb, DMG.
|
||||
* Нет настроек Gitea или файлов клиентов — `404 app.update_unavailable`.
|
||||
*/
|
||||
get: operations["getAppClients"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/app/download/{client}/{version}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Файл клиента (кэшируется на сервере, поддерживает Range) */
|
||||
get: operations["downloadAppClient"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/app/android/latest": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -2249,6 +2287,22 @@ export interface components {
|
||||
early_skip_rate: number | null;
|
||||
like_rate: number | null;
|
||||
};
|
||||
/** @enum {string} */
|
||||
AppClient: "android" | "windows" | "linux" | "macos";
|
||||
AppClientFile: {
|
||||
client: components["schemas"]["AppClient"];
|
||||
filename: string;
|
||||
/** Format: int64 */
|
||||
size: number;
|
||||
sha256?: string;
|
||||
download_url: string;
|
||||
};
|
||||
AppClients: {
|
||||
version: string;
|
||||
version_code: number;
|
||||
notes_md: string;
|
||||
clients: components["schemas"]["AppClientFile"][];
|
||||
};
|
||||
AndroidRelease: {
|
||||
/** @example 0.6.1 */
|
||||
version: string;
|
||||
@@ -4189,6 +4243,51 @@ export interface operations {
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
getAppClients: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Доступные сборки */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["AppClients"];
|
||||
};
|
||||
};
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
downloadAppClient: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
client: components["schemas"]["AppClient"];
|
||||
version: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Установщик или APK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/octet-stream": string;
|
||||
};
|
||||
};
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
getAndroidLatest: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -22,17 +22,41 @@
|
||||
"logout": "Sign out"
|
||||
},
|
||||
"app": {
|
||||
"link": "Android app",
|
||||
"title": "pipu for Android",
|
||||
"subtitle": "Your music from this server on your phone: My Wave, your collection and offline listening.",
|
||||
"download": "Download APK",
|
||||
"link": "Download apps",
|
||||
"badge": "Apps",
|
||||
"title": "pipu on your devices",
|
||||
"subtitle": "Clients for this server: phone, desktop and the web. The build for your system comes first.",
|
||||
"platform": {
|
||||
"android": "Android",
|
||||
"windows": "Windows",
|
||||
"linux": "Linux",
|
||||
"macos": "macOS"
|
||||
},
|
||||
"download": {
|
||||
"android": "Download APK",
|
||||
"windows": "Download for Windows",
|
||||
"linux": "Download AppImage",
|
||||
"macos": "Download for macOS"
|
||||
},
|
||||
"version": "version {{version}}",
|
||||
"step_install": "Open the downloaded file. If Android asks, allow installs from this source.",
|
||||
"other": "Other platforms",
|
||||
"step_install": {
|
||||
"android": "Open the downloaded file. If Android asks, allow installs from this source.",
|
||||
"windows": "Run the installer and follow the steps. pipu will appear in the Start menu.",
|
||||
"linux": "Make the file executable and open it. For a .deb, install the package.",
|
||||
"macos": "Open the disk image and drag pipu into Applications."
|
||||
},
|
||||
"step_server": "Launch pipu and enter the server address:",
|
||||
"copy_server": "Copy the server address",
|
||||
"step_updates": "Sign in with your account. The app offers new versions by itself.",
|
||||
"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."
|
||||
},
|
||||
"checksum": "SHA-256 checksum",
|
||||
"unavailable": "The app is not available yet: release downloads are not configured on this server (docs/deployment.md).",
|
||||
"unavailable": "Apps are not available yet: release downloads are not configured on this server (docs/deployment.md).",
|
||||
"unavailable_platform": "The latest release does not include a build for {{platform}} yet.",
|
||||
"to_login": "Sign in to the web app"
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,7 @@
|
||||
"menu": "Profile menu",
|
||||
"settings": "Settings",
|
||||
"logout": "Sign out",
|
||||
"android_app": "Android app"
|
||||
"download_apps": "Download apps"
|
||||
},
|
||||
"roles": {
|
||||
"admin": "Administrator",
|
||||
|
||||
@@ -22,17 +22,41 @@
|
||||
"logout": "Выйти"
|
||||
},
|
||||
"app": {
|
||||
"link": "Приложение для Android",
|
||||
"title": "pipu для Android",
|
||||
"subtitle": "Ваша музыка с этого сервера на телефоне: «Моя волна», коллекция и офлайн-прослушивание.",
|
||||
"download": "Скачать APK",
|
||||
"link": "Скачать приложения",
|
||||
"badge": "Приложения",
|
||||
"title": "pipu на ваших устройствах",
|
||||
"subtitle": "Клиенты для этого сервера: телефон, компьютер и веб. Сначала предлагается сборка под вашу систему.",
|
||||
"platform": {
|
||||
"android": "Android",
|
||||
"windows": "Windows",
|
||||
"linux": "Linux",
|
||||
"macos": "macOS"
|
||||
},
|
||||
"download": {
|
||||
"android": "Скачать APK",
|
||||
"windows": "Скачать для Windows",
|
||||
"linux": "Скачать AppImage",
|
||||
"macos": "Скачать для macOS"
|
||||
},
|
||||
"version": "версия {{version}}",
|
||||
"step_install": "Откройте скачанный файл. Если Android спросит — разрешите установку из этого источника.",
|
||||
"other": "Другие платформы",
|
||||
"step_install": {
|
||||
"android": "Откройте скачанный файл. Если Android спросит — разрешите установку из этого источника.",
|
||||
"windows": "Запустите установщик и следуйте шагам. pipu появится в меню Пуск.",
|
||||
"linux": "Сделайте файл исполняемым и откройте его. Для .deb — установите пакет.",
|
||||
"macos": "Откройте образ и перетащите pipu в Программы."
|
||||
},
|
||||
"step_server": "Запустите pipu и введите адрес сервера:",
|
||||
"copy_server": "Скопировать адрес сервера",
|
||||
"step_updates": "Войдите своим логином. Новые версии приложение предложит установить само.",
|
||||
"step_updates": {
|
||||
"android": "Войдите своим логином. Новые версии приложение предложит установить само.",
|
||||
"windows": "Войдите своим логином. Новую версию скачайте с этой страницы.",
|
||||
"linux": "Войдите своим логином. Новую версию скачайте с этой страницы.",
|
||||
"macos": "Войдите своим логином. Новую версию скачайте с этой страницы."
|
||||
},
|
||||
"checksum": "Контрольная сумма SHA-256",
|
||||
"unavailable": "Приложение пока недоступно: на сервере не настроена загрузка релизов (docs/deployment.md).",
|
||||
"unavailable": "Приложения пока недоступны: на сервере не настроена загрузка релизов (docs/deployment.md).",
|
||||
"unavailable_platform": "В последнем релизе пока нет сборки для {{platform}}.",
|
||||
"to_login": "Войти в веб-версию"
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,7 @@
|
||||
"menu": "Меню профиля",
|
||||
"settings": "Настройки",
|
||||
"logout": "Выйти",
|
||||
"android_app": "Приложение для Android"
|
||||
"download_apps": "Скачать приложения"
|
||||
},
|
||||
"roles": {
|
||||
"admin": "Администратор",
|
||||
|
||||
Generated
+133
-4
@@ -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
|
||||
|
||||
@@ -28,6 +28,14 @@ const (
|
||||
BearerAuthScopes = "bearerAuth.Scopes"
|
||||
)
|
||||
|
||||
// Defines values for AppClient.
|
||||
const (
|
||||
AppClientAndroid AppClient = "android"
|
||||
AppClientLinux AppClient = "linux"
|
||||
AppClientMacos AppClient = "macos"
|
||||
AppClientWindows AppClient = "windows"
|
||||
)
|
||||
|
||||
// Defines values for AuthTokensTokenType.
|
||||
const (
|
||||
Bearer AuthTokensTokenType = "Bearer"
|
||||
@@ -35,9 +43,9 @@ const (
|
||||
|
||||
// Defines values for ClientType.
|
||||
const (
|
||||
Android ClientType = "android"
|
||||
Desktop ClientType = "desktop"
|
||||
Web ClientType = "web"
|
||||
ClientTypeAndroid ClientType = "android"
|
||||
ClientTypeDesktop ClientType = "desktop"
|
||||
ClientTypeWeb ClientType = "web"
|
||||
)
|
||||
|
||||
// Defines values for HealthStatusStatus.
|
||||
@@ -319,6 +327,26 @@ type AndroidRelease struct {
|
||||
VersionCode int `json:"version_code"`
|
||||
}
|
||||
|
||||
// AppClient defines model for AppClient.
|
||||
type AppClient string
|
||||
|
||||
// AppClientFile defines model for AppClientFile.
|
||||
type AppClientFile struct {
|
||||
Client AppClient `json:"client"`
|
||||
DownloadUrl string `json:"download_url"`
|
||||
Filename string `json:"filename"`
|
||||
Sha256 *string `json:"sha256,omitempty"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// AppClients defines model for AppClients.
|
||||
type AppClients struct {
|
||||
Clients []AppClientFile `json:"clients"`
|
||||
NotesMd string `json:"notes_md"`
|
||||
Version string `json:"version"`
|
||||
VersionCode int `json:"version_code"`
|
||||
}
|
||||
|
||||
// Artist defines model for Artist.
|
||||
type Artist struct {
|
||||
AlbumCount int `json:"album_count"`
|
||||
@@ -1688,6 +1716,12 @@ type ServerInterface interface {
|
||||
// Последняя версия Android-приложения (из релизов Gitea через сервер)
|
||||
// (GET /api/v1/app/android/latest)
|
||||
GetAndroidLatest(w http.ResponseWriter, r *http.Request)
|
||||
// Клиенты последнего релиза (Android и десктоп)
|
||||
// (GET /api/v1/app/clients)
|
||||
GetAppClients(w http.ResponseWriter, r *http.Request)
|
||||
// Файл клиента (кэшируется на сервере, поддерживает Range)
|
||||
// (GET /api/v1/app/download/{client}/{version})
|
||||
DownloadAppClient(w http.ResponseWriter, r *http.Request, client AppClient, version string)
|
||||
// Исполнитель
|
||||
// (GET /api/v1/artists/{id})
|
||||
GetArtist(w http.ResponseWriter, r *http.Request, id EntityId)
|
||||
@@ -2054,6 +2088,18 @@ func (_ Unimplemented) GetAndroidLatest(w http.ResponseWriter, r *http.Request)
|
||||
w.WriteHeader(http.StatusNotImplemented)
|
||||
}
|
||||
|
||||
// Клиенты последнего релиза (Android и десктоп)
|
||||
// (GET /api/v1/app/clients)
|
||||
func (_ Unimplemented) GetAppClients(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotImplemented)
|
||||
}
|
||||
|
||||
// Файл клиента (кэшируется на сервере, поддерживает Range)
|
||||
// (GET /api/v1/app/download/{client}/{version})
|
||||
func (_ Unimplemented) DownloadAppClient(w http.ResponseWriter, r *http.Request, client AppClient, version string) {
|
||||
w.WriteHeader(http.StatusNotImplemented)
|
||||
}
|
||||
|
||||
// Исполнитель
|
||||
// (GET /api/v1/artists/{id})
|
||||
func (_ Unimplemented) GetArtist(w http.ResponseWriter, r *http.Request, id EntityId) {
|
||||
@@ -3438,6 +3484,54 @@ func (siw *ServerInterfaceWrapper) GetAndroidLatest(w http.ResponseWriter, r *ht
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// GetAppClients operation middleware
|
||||
func (siw *ServerInterfaceWrapper) GetAppClients(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
siw.Handler.GetAppClients(w, r)
|
||||
}))
|
||||
|
||||
for _, middleware := range siw.HandlerMiddlewares {
|
||||
handler = middleware(handler)
|
||||
}
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// DownloadAppClient operation middleware
|
||||
func (siw *ServerInterfaceWrapper) DownloadAppClient(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var err error
|
||||
|
||||
// ------------- Path parameter "client" -------------
|
||||
var client AppClient
|
||||
|
||||
err = runtime.BindStyledParameterWithOptions("simple", "client", chi.URLParam(r, "client"), &client, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true})
|
||||
if err != nil {
|
||||
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "client", Err: err})
|
||||
return
|
||||
}
|
||||
|
||||
// ------------- Path parameter "version" -------------
|
||||
var version string
|
||||
|
||||
err = runtime.BindStyledParameterWithOptions("simple", "version", chi.URLParam(r, "version"), &version, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true})
|
||||
if err != nil {
|
||||
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "version", Err: err})
|
||||
return
|
||||
}
|
||||
|
||||
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
siw.Handler.DownloadAppClient(w, r, client, version)
|
||||
}))
|
||||
|
||||
for _, middleware := range siw.HandlerMiddlewares {
|
||||
handler = middleware(handler)
|
||||
}
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// GetArtist operation middleware
|
||||
func (siw *ServerInterfaceWrapper) GetArtist(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -5074,6 +5168,12 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Get(options.BaseURL+"/api/v1/app/android/latest", wrapper.GetAndroidLatest)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Get(options.BaseURL+"/api/v1/app/clients", wrapper.GetAppClients)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Get(options.BaseURL+"/api/v1/app/download/{client}/{version}", wrapper.DownloadAppClient)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Get(options.BaseURL+"/api/v1/artists/{id}", wrapper.GetArtist)
|
||||
})
|
||||
@@ -6269,6 +6369,74 @@ func (response GetAndroidLatestdefaultApplicationProblemPlusJSONResponse) VisitG
|
||||
return json.NewEncoder(w).Encode(response.Body)
|
||||
}
|
||||
|
||||
type GetAppClientsRequestObject struct {
|
||||
}
|
||||
|
||||
type GetAppClientsResponseObject interface {
|
||||
VisitGetAppClientsResponse(w http.ResponseWriter) error
|
||||
}
|
||||
|
||||
type GetAppClients200JSONResponse AppClients
|
||||
|
||||
func (response GetAppClients200JSONResponse) VisitGetAppClientsResponse(w http.ResponseWriter) error {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(200)
|
||||
|
||||
return json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
type GetAppClientsdefaultApplicationProblemPlusJSONResponse struct {
|
||||
Body Problem
|
||||
StatusCode int
|
||||
}
|
||||
|
||||
func (response GetAppClientsdefaultApplicationProblemPlusJSONResponse) VisitGetAppClientsResponse(w http.ResponseWriter) error {
|
||||
w.Header().Set("Content-Type", "application/problem+json")
|
||||
w.WriteHeader(response.StatusCode)
|
||||
|
||||
return json.NewEncoder(w).Encode(response.Body)
|
||||
}
|
||||
|
||||
type DownloadAppClientRequestObject struct {
|
||||
Client AppClient `json:"client"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
type DownloadAppClientResponseObject interface {
|
||||
VisitDownloadAppClientResponse(w http.ResponseWriter) error
|
||||
}
|
||||
|
||||
type DownloadAppClient200ApplicationoctetStreamResponse struct {
|
||||
Body io.Reader
|
||||
ContentLength int64
|
||||
}
|
||||
|
||||
func (response DownloadAppClient200ApplicationoctetStreamResponse) VisitDownloadAppClientResponse(w http.ResponseWriter) error {
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
if response.ContentLength != 0 {
|
||||
w.Header().Set("Content-Length", fmt.Sprint(response.ContentLength))
|
||||
}
|
||||
w.WriteHeader(200)
|
||||
|
||||
if closer, ok := response.Body.(io.ReadCloser); ok {
|
||||
defer closer.Close()
|
||||
}
|
||||
_, err := io.Copy(w, response.Body)
|
||||
return err
|
||||
}
|
||||
|
||||
type DownloadAppClientdefaultApplicationProblemPlusJSONResponse struct {
|
||||
Body Problem
|
||||
StatusCode int
|
||||
}
|
||||
|
||||
func (response DownloadAppClientdefaultApplicationProblemPlusJSONResponse) VisitDownloadAppClientResponse(w http.ResponseWriter) error {
|
||||
w.Header().Set("Content-Type", "application/problem+json")
|
||||
w.WriteHeader(response.StatusCode)
|
||||
|
||||
return json.NewEncoder(w).Encode(response.Body)
|
||||
}
|
||||
|
||||
type GetArtistRequestObject struct {
|
||||
Id EntityId `json:"id"`
|
||||
}
|
||||
@@ -7816,6 +7984,12 @@ type StrictServerInterface interface {
|
||||
// Последняя версия Android-приложения (из релизов Gitea через сервер)
|
||||
// (GET /api/v1/app/android/latest)
|
||||
GetAndroidLatest(ctx context.Context, request GetAndroidLatestRequestObject) (GetAndroidLatestResponseObject, error)
|
||||
// Клиенты последнего релиза (Android и десктоп)
|
||||
// (GET /api/v1/app/clients)
|
||||
GetAppClients(ctx context.Context, request GetAppClientsRequestObject) (GetAppClientsResponseObject, error)
|
||||
// Файл клиента (кэшируется на сервере, поддерживает Range)
|
||||
// (GET /api/v1/app/download/{client}/{version})
|
||||
DownloadAppClient(ctx context.Context, request DownloadAppClientRequestObject) (DownloadAppClientResponseObject, error)
|
||||
// Исполнитель
|
||||
// (GET /api/v1/artists/{id})
|
||||
GetArtist(ctx context.Context, request GetArtistRequestObject) (GetArtistResponseObject, error)
|
||||
@@ -8972,6 +9146,57 @@ func (sh *strictHandler) GetAndroidLatest(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
}
|
||||
|
||||
// GetAppClients operation middleware
|
||||
func (sh *strictHandler) GetAppClients(w http.ResponseWriter, r *http.Request) {
|
||||
var request GetAppClientsRequestObject
|
||||
|
||||
handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) {
|
||||
return sh.ssi.GetAppClients(ctx, request.(GetAppClientsRequestObject))
|
||||
}
|
||||
for _, middleware := range sh.middlewares {
|
||||
handler = middleware(handler, "GetAppClients")
|
||||
}
|
||||
|
||||
response, err := handler(r.Context(), w, r, request)
|
||||
|
||||
if err != nil {
|
||||
sh.options.ResponseErrorHandlerFunc(w, r, err)
|
||||
} else if validResponse, ok := response.(GetAppClientsResponseObject); ok {
|
||||
if err := validResponse.VisitGetAppClientsResponse(w); err != nil {
|
||||
sh.options.ResponseErrorHandlerFunc(w, r, err)
|
||||
}
|
||||
} else if response != nil {
|
||||
sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response))
|
||||
}
|
||||
}
|
||||
|
||||
// DownloadAppClient operation middleware
|
||||
func (sh *strictHandler) DownloadAppClient(w http.ResponseWriter, r *http.Request, client AppClient, version string) {
|
||||
var request DownloadAppClientRequestObject
|
||||
|
||||
request.Client = client
|
||||
request.Version = version
|
||||
|
||||
handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) {
|
||||
return sh.ssi.DownloadAppClient(ctx, request.(DownloadAppClientRequestObject))
|
||||
}
|
||||
for _, middleware := range sh.middlewares {
|
||||
handler = middleware(handler, "DownloadAppClient")
|
||||
}
|
||||
|
||||
response, err := handler(r.Context(), w, r, request)
|
||||
|
||||
if err != nil {
|
||||
sh.options.ResponseErrorHandlerFunc(w, r, err)
|
||||
} else if validResponse, ok := response.(DownloadAppClientResponseObject); ok {
|
||||
if err := validResponse.VisitDownloadAppClientResponse(w); err != nil {
|
||||
sh.options.ResponseErrorHandlerFunc(w, r, err)
|
||||
}
|
||||
} else if response != nil {
|
||||
sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response))
|
||||
}
|
||||
}
|
||||
|
||||
// GetArtist operation middleware
|
||||
func (sh *strictHandler) GetArtist(w http.ResponseWriter, r *http.Request, id EntityId) {
|
||||
var request GetArtistRequestObject
|
||||
@@ -10343,273 +10568,278 @@ func (sh *strictHandler) GetReadyz(w http.ResponseWriter, r *http.Request) {
|
||||
// Base64 encoded, gzipped, json marshaled Swagger object
|
||||
var swaggerSpec = []string{
|
||||
|
||||
"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",
|
||||
"zW9kz1wBAA==",
|
||||
"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",
|
||||
"AQA=",
|
||||
}
|
||||
|
||||
// GetSwagger returns the content of the embedded swagger specification file
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Package appupdate — самообновление Android-приложения через сервер (docs/mobile.md#самообновление):
|
||||
// сервер читает последний релиз Gitea своим токеном, кэширует ответ и APK.
|
||||
// Package appupdate — раздача клиентов через сервер (docs/mobile.md#самообновление):
|
||||
// сервер читает последний релиз Gitea своим токеном, кэширует ответ и файлы.
|
||||
package appupdate
|
||||
|
||||
import (
|
||||
@@ -25,7 +25,7 @@ import (
|
||||
"pipu/api/internal/platform/httpx"
|
||||
)
|
||||
|
||||
// CodeUnavailable — обновления не настроены или релиза с APK нет.
|
||||
// CodeUnavailable — обновления не настроены или релиза с клиентами нет.
|
||||
const CodeUnavailable = "app.update_unavailable"
|
||||
|
||||
const cacheTTL = 10 * time.Minute
|
||||
@@ -73,14 +73,22 @@ type release struct {
|
||||
Assets []asset `json:"assets"`
|
||||
}
|
||||
|
||||
// Latest — сведения о последней версии.
|
||||
type clientFile struct {
|
||||
Kind, Name, URL, ChecksumURL, SHA256 string
|
||||
Size int64
|
||||
}
|
||||
|
||||
// Latest — сведения о последней версии и файлах клиентов.
|
||||
type Latest struct {
|
||||
Version string
|
||||
VersionCode int
|
||||
Notes string
|
||||
Size int64
|
||||
SHA256 string
|
||||
apkURL string
|
||||
Files map[string]clientFile
|
||||
}
|
||||
|
||||
func (l *Latest) android() (clientFile, bool) {
|
||||
f, ok := l.Files["android"]
|
||||
return f, ok && f.SHA256 != ""
|
||||
}
|
||||
|
||||
// Handler реализует app-часть gen.StrictServerInterface.
|
||||
@@ -92,7 +100,7 @@ type Handler struct {
|
||||
mu sync.Mutex
|
||||
cached *Latest
|
||||
cachedAt time.Time
|
||||
dl sync.Mutex // одна загрузка APK за раз
|
||||
dl sync.Mutex
|
||||
}
|
||||
|
||||
// NewHandler создаёт обработчик.
|
||||
@@ -100,7 +108,6 @@ func NewHandler(cfg Config, log *slog.Logger) *Handler {
|
||||
return &Handler{cfg: cfg, client: &http.Client{Timeout: 10 * time.Minute}, log: log}
|
||||
}
|
||||
|
||||
// configured — адрес и репозиторий заданы, и это не заглушка из .env.example.
|
||||
func (h *Handler) configured() bool {
|
||||
return h.cfg.GiteaURL != "" && h.cfg.Repo != "" &&
|
||||
!strings.Contains(h.cfg.GiteaURL, "example.") && !strings.HasPrefix(h.cfg.Repo, "owner/")
|
||||
@@ -125,7 +132,70 @@ func (h *Handler) get(ctx context.Context, url string) (*http.Response, error) {
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// latest — последний релиз с APK (кэш на 10 минут).
|
||||
func classifyAsset(name string) string {
|
||||
n := strings.ToLower(name)
|
||||
if strings.HasSuffix(n, ".sha256") {
|
||||
return ""
|
||||
}
|
||||
switch {
|
||||
case strings.HasSuffix(n, ".apk"):
|
||||
return "android"
|
||||
case strings.HasSuffix(n, ".exe"), strings.HasSuffix(n, ".msi"):
|
||||
return "windows"
|
||||
case strings.HasSuffix(n, ".appimage"), strings.HasSuffix(n, ".deb"), strings.HasSuffix(n, ".rpm"):
|
||||
return "linux"
|
||||
case strings.HasSuffix(n, ".dmg"), strings.Contains(n, ".app.tar"):
|
||||
return "macos"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func assetRank(kind, name string) int {
|
||||
n := strings.ToLower(name)
|
||||
switch kind {
|
||||
case "windows":
|
||||
if strings.Contains(n, "setup") {
|
||||
return 2
|
||||
}
|
||||
return 1
|
||||
case "linux":
|
||||
switch {
|
||||
case strings.HasSuffix(n, ".appimage"):
|
||||
return 3
|
||||
case strings.HasSuffix(n, ".deb"):
|
||||
return 2
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
func pickClientFiles(assets []asset) map[string]clientFile {
|
||||
sums := map[string]string{}
|
||||
for _, a := range assets {
|
||||
if strings.HasSuffix(strings.ToLower(a.Name), ".sha256") {
|
||||
sums[strings.TrimSuffix(a.Name, ".sha256")] = a.URL
|
||||
sums[strings.TrimSuffix(a.Name, ".SHA256")] = a.URL
|
||||
}
|
||||
}
|
||||
out := map[string]clientFile{}
|
||||
for _, a := range assets {
|
||||
kind := classifyAsset(a.Name)
|
||||
if kind == "" {
|
||||
continue
|
||||
}
|
||||
cur, ok := out[kind]
|
||||
if ok && assetRank(kind, a.Name) <= assetRank(kind, cur.Name) {
|
||||
continue
|
||||
}
|
||||
out[kind] = clientFile{Kind: kind, Name: a.Name, URL: a.URL, Size: a.Size, ChecksumURL: sums[a.Name]}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (h *Handler) latest(ctx context.Context) (*Latest, error) {
|
||||
if !h.configured() {
|
||||
return nil, apperr.NotFound(CodeUnavailable, "gitea is not configured")
|
||||
@@ -142,7 +212,7 @@ func (h *Handler) latest(ctx context.Context) (*Latest, error) {
|
||||
if err != nil {
|
||||
h.log.WarnContext(ctx, "app update: gitea unavailable", "err", err)
|
||||
if h.cached != nil {
|
||||
return h.cached, nil // устаревший ответ лучше, чем никакого
|
||||
return h.cached, nil
|
||||
}
|
||||
return nil, apperr.NotFound(CodeUnavailable, "release is unavailable")
|
||||
}
|
||||
@@ -156,35 +226,77 @@ func (h *Handler) latest(ctx context.Context) (*Latest, error) {
|
||||
if err != nil {
|
||||
return nil, apperr.NotFound(CodeUnavailable, err.Error())
|
||||
}
|
||||
name := "pipu-music-" + version + ".apk"
|
||||
var apk, sum *asset
|
||||
for i := range rel.Assets {
|
||||
switch rel.Assets[i].Name {
|
||||
case name:
|
||||
apk = &rel.Assets[i]
|
||||
case name + ".sha256":
|
||||
sum = &rel.Assets[i]
|
||||
files := pickClientFiles(rel.Assets)
|
||||
if len(files) == 0 {
|
||||
return nil, apperr.NotFound(CodeUnavailable, "release has no client builds")
|
||||
}
|
||||
if apk, ok := files["android"]; ok {
|
||||
if apk.ChecksumURL == "" {
|
||||
delete(files, "android")
|
||||
} else if digest, err := h.readDigest(ctx, apk.ChecksumURL); err != nil {
|
||||
h.log.WarnContext(ctx, "app update: apk checksum unavailable", "err", err)
|
||||
delete(files, "android")
|
||||
} else {
|
||||
apk.SHA256 = digest
|
||||
files["android"] = apk
|
||||
}
|
||||
if len(files) == 0 {
|
||||
return nil, apperr.NotFound(CodeUnavailable, "release has no APK yet")
|
||||
}
|
||||
}
|
||||
if apk == nil || sum == nil {
|
||||
return nil, apperr.NotFound(CodeUnavailable, "release has no APK yet")
|
||||
}
|
||||
sres, err := h.get(ctx, sum.URL)
|
||||
h.cached = &Latest{Version: version, VersionCode: code, Notes: rel.Body, Files: files}
|
||||
h.cachedAt = time.Now()
|
||||
return h.cached, nil
|
||||
}
|
||||
|
||||
func (h *Handler) readDigest(ctx context.Context, url string) (string, error) {
|
||||
sres, err := h.get(ctx, url)
|
||||
if err != nil {
|
||||
return nil, apperr.NotFound(CodeUnavailable, "checksum is unavailable")
|
||||
return "", err
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(sres.Body, 256))
|
||||
_ = sres.Body.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return "", err
|
||||
}
|
||||
digest := strings.ToLower(strings.Fields(string(raw) + " x")[0])
|
||||
if len(digest) != 64 {
|
||||
return nil, apperr.NotFound(CodeUnavailable, "malformed checksum")
|
||||
return "", fmt.Errorf("malformed checksum")
|
||||
}
|
||||
h.cached = &Latest{Version: version, VersionCode: code, Notes: rel.Body, Size: apk.Size, SHA256: digest, apkURL: apk.URL}
|
||||
h.cachedAt = time.Now()
|
||||
return h.cached, nil
|
||||
return digest, nil
|
||||
}
|
||||
|
||||
func (h *Handler) fileURL(kind, version string) string {
|
||||
return "/api/v1/app/download/" + kind + "/" + version
|
||||
}
|
||||
|
||||
// GetAppClients реализует gen.StrictServerInterface.
|
||||
func (h *Handler) GetAppClients(ctx context.Context, _ gen.GetAppClientsRequestObject) (gen.GetAppClientsResponseObject, error) {
|
||||
l, err := h.latest(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
order := []string{"windows", "macos", "linux", "android"}
|
||||
clients := make([]gen.AppClientFile, 0, len(l.Files))
|
||||
for _, kind := range order {
|
||||
f, ok := l.Files[kind]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
item := gen.AppClientFile{
|
||||
Client: gen.AppClient(kind),
|
||||
Filename: f.Name,
|
||||
Size: f.Size,
|
||||
DownloadUrl: h.fileURL(kind, l.Version),
|
||||
}
|
||||
if f.SHA256 != "" {
|
||||
item.Sha256 = &f.SHA256
|
||||
}
|
||||
clients = append(clients, item)
|
||||
}
|
||||
return gen.GetAppClients200JSONResponse{
|
||||
Version: l.Version, VersionCode: l.VersionCode, NotesMd: l.Notes, Clients: clients,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetAndroidLatest реализует gen.StrictServerInterface.
|
||||
@@ -193,15 +305,18 @@ func (h *Handler) GetAndroidLatest(ctx context.Context, _ gen.GetAndroidLatestRe
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f, ok := l.android()
|
||||
if !ok {
|
||||
return nil, apperr.NotFound(CodeUnavailable, "release has no APK yet")
|
||||
}
|
||||
return gen.GetAndroidLatest200JSONResponse{
|
||||
Version: l.Version, VersionCode: l.VersionCode, NotesMd: l.Notes, Size: l.Size, Sha256: l.SHA256,
|
||||
Version: l.Version, VersionCode: l.VersionCode, NotesMd: l.Notes, Size: f.Size, Sha256: f.SHA256,
|
||||
DownloadUrl: "/api/v1/app/android/download/" + l.Version, MinSupported: h.cfg.MinSupported,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ensure скачивает APK в кэш (если его там нет) и сверяет SHA-256 из релиза.
|
||||
func (h *Handler) ensure(ctx context.Context, l *Latest) (string, error) {
|
||||
dst := filepath.Join(h.cfg.Dir, "pipu-music-"+l.Version+".apk")
|
||||
func (h *Handler) ensure(ctx context.Context, f clientFile) (string, error) {
|
||||
dst := filepath.Join(h.cfg.Dir, f.Name)
|
||||
if _, err := os.Stat(dst); err == nil {
|
||||
return dst, nil
|
||||
}
|
||||
@@ -213,12 +328,12 @@ func (h *Handler) ensure(ctx context.Context, l *Latest) (string, error) {
|
||||
if err := os.MkdirAll(h.cfg.Dir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
res, err := h.get(ctx, l.apkURL)
|
||||
res, err := h.get(ctx, f.URL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
tmp, err := os.CreateTemp(h.cfg.Dir, ".apk-*")
|
||||
tmp, err := os.CreateTemp(h.cfg.Dir, ".dl-*")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -231,26 +346,53 @@ func (h *Handler) ensure(ctx context.Context, l *Latest) (string, error) {
|
||||
if err := tmp.Close(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if got := hex.EncodeToString(hash.Sum(nil)); got != l.SHA256 {
|
||||
return "", fmt.Errorf("appupdate: checksum mismatch: %s != %s", got, l.SHA256)
|
||||
if f.SHA256 != "" {
|
||||
if got := hex.EncodeToString(hash.Sum(nil)); got != f.SHA256 {
|
||||
return "", fmt.Errorf("appupdate: checksum mismatch: %s != %s", got, f.SHA256)
|
||||
}
|
||||
}
|
||||
if err := os.Rename(tmp.Name(), dst); err != nil {
|
||||
return "", err
|
||||
}
|
||||
h.cleanup(dst)
|
||||
h.cleanup(f.Name)
|
||||
return dst, nil
|
||||
}
|
||||
|
||||
// cleanup оставляет в кэше только текущий APK.
|
||||
func (h *Handler) cleanup(keep string) {
|
||||
matches, _ := filepath.Glob(filepath.Join(h.cfg.Dir, "pipu-music-*.apk"))
|
||||
func (h *Handler) cleanup(keepName string) {
|
||||
matches, _ := filepath.Glob(filepath.Join(h.cfg.Dir, "pipu-music-*"))
|
||||
ver := versionFromName(keepName)
|
||||
for _, m := range matches {
|
||||
if m != keep {
|
||||
base := filepath.Base(m)
|
||||
if ver != "" && strings.Contains(base, ver) {
|
||||
continue
|
||||
}
|
||||
if base != keepName {
|
||||
_ = os.Remove(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func versionFromName(name string) string {
|
||||
m := regexp.MustCompile(`(\d+\.\d+\.\d+(?:-rc\.\d+)?)`).FindStringSubmatch(name)
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
return m[1]
|
||||
}
|
||||
|
||||
func (h *Handler) serveFile(ctx context.Context, l *Latest, kind string) (fileResponse, error) {
|
||||
f, ok := l.Files[kind]
|
||||
if !ok || (kind == "android" && f.SHA256 == "") {
|
||||
return fileResponse{}, apperr.NotFound(CodeUnavailable, "client build is unavailable")
|
||||
}
|
||||
path, err := h.ensure(ctx, f)
|
||||
if err != nil {
|
||||
h.log.ErrorContext(ctx, "app update: download failed", "err", err, "client", kind)
|
||||
return fileResponse{}, apperr.New(http.StatusBadGateway, CodeUnavailable, "could not fetch the file")
|
||||
}
|
||||
return fileResponse{path: path, name: f.Name, req: httpx.RequestFrom(ctx)}, nil
|
||||
}
|
||||
|
||||
// DownloadAndroidApk реализует gen.StrictServerInterface. Отдаётся только последняя версия.
|
||||
func (h *Handler) DownloadAndroidApk(ctx context.Context, req gen.DownloadAndroidApkRequestObject) (gen.DownloadAndroidApkResponseObject, error) {
|
||||
l, err := h.latest(ctx)
|
||||
@@ -260,22 +402,36 @@ func (h *Handler) DownloadAndroidApk(ctx context.Context, req gen.DownloadAndroi
|
||||
if req.Version != l.Version {
|
||||
return nil, apperr.NotFound(CodeUnavailable, "only the latest version is available")
|
||||
}
|
||||
path, err := h.ensure(ctx, l)
|
||||
if err != nil {
|
||||
h.log.ErrorContext(ctx, "app update: apk download failed", "err", err)
|
||||
return nil, apperr.New(http.StatusBadGateway, CodeUnavailable, "could not fetch the APK")
|
||||
}
|
||||
return apkResponse{path: path, name: filepath.Base(path), req: httpx.RequestFrom(ctx)}, nil
|
||||
return h.serveFile(ctx, l, "android")
|
||||
}
|
||||
|
||||
type apkResponse struct {
|
||||
// DownloadAppClient реализует gen.StrictServerInterface.
|
||||
func (h *Handler) DownloadAppClient(ctx context.Context, req gen.DownloadAppClientRequestObject) (gen.DownloadAppClientResponseObject, error) {
|
||||
l, err := h.latest(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.Version != l.Version {
|
||||
return nil, apperr.NotFound(CodeUnavailable, "only the latest version is available")
|
||||
}
|
||||
return h.serveFile(ctx, l, string(req.Client))
|
||||
}
|
||||
|
||||
type fileResponse struct {
|
||||
path string
|
||||
name string
|
||||
req *http.Request
|
||||
}
|
||||
|
||||
// VisitDownloadAndroidApkResponse реализует gen.DownloadAndroidApkResponseObject (Range — для докачки).
|
||||
func (a apkResponse) VisitDownloadAndroidApkResponse(w http.ResponseWriter) error {
|
||||
func (a fileResponse) VisitDownloadAndroidApkResponse(w http.ResponseWriter) error {
|
||||
return a.write(w)
|
||||
}
|
||||
|
||||
func (a fileResponse) VisitDownloadAppClientResponse(w http.ResponseWriter) error {
|
||||
return a.write(w)
|
||||
}
|
||||
|
||||
func (a fileResponse) write(w http.ResponseWriter) error {
|
||||
f, err := os.Open(a.path)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -288,7 +444,11 @@ func (a apkResponse) VisitDownloadAndroidApkResponse(w http.ResponseWriter) erro
|
||||
if a.req == nil {
|
||||
return errors.New("appupdate: request missing in context")
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/vnd.android.package-archive")
|
||||
ctype := "application/octet-stream"
|
||||
if strings.HasSuffix(strings.ToLower(a.name), ".apk") {
|
||||
ctype = "application/vnd.android.package-archive"
|
||||
}
|
||||
w.Header().Set("Content-Type", ctype)
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="`+a.name+`"`)
|
||||
http.ServeContent(w, a.req, a.name, info.ModTime(), f)
|
||||
return nil
|
||||
|
||||
@@ -16,6 +16,22 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPickClientFiles(t *testing.T) {
|
||||
files := pickClientFiles([]asset{
|
||||
{Name: "pipu-music-1.3.0.apk", Size: 10},
|
||||
{Name: "pipu-music-1.3.0.apk.sha256", Size: 65, URL: "https://gitea/sum"},
|
||||
{Name: "pipu-music_1.3.0_x64-setup.exe", Size: 20},
|
||||
{Name: "pipu-music_1.3.0_amd64.deb", Size: 11},
|
||||
{Name: "pipu-music_1.3.0_amd64.AppImage", Size: 30},
|
||||
{Name: "pipu-music_1.3.0_aarch64.dmg", Size: 40},
|
||||
})
|
||||
assert.Equal(t, "pipu-music-1.3.0.apk", files["android"].Name)
|
||||
assert.Equal(t, "https://gitea/sum", files["android"].ChecksumURL)
|
||||
assert.Equal(t, "pipu-music_1.3.0_x64-setup.exe", files["windows"].Name)
|
||||
assert.Equal(t, "pipu-music_1.3.0_amd64.AppImage", files["linux"].Name)
|
||||
assert.Equal(t, "pipu-music_1.3.0_aarch64.dmg", files["macos"].Name)
|
||||
}
|
||||
|
||||
func TestVersionCode(t *testing.T) {
|
||||
cases := map[string]int{"v0.6.1": 60_199, "1.2.0-rc.3": 1_020_003, "v10.0.0": 10_000_099}
|
||||
for tag, want := range cases {
|
||||
@@ -60,17 +76,18 @@ func TestLatestAndDownload(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "0.6.1", l.Version)
|
||||
assert.Equal(t, 60_199, l.VersionCode)
|
||||
assert.Equal(t, hex.EncodeToString(sum[:]), l.SHA256)
|
||||
android := l.Files["android"]
|
||||
assert.Equal(t, hex.EncodeToString(sum[:]), android.SHA256)
|
||||
|
||||
path, err := h.ensure(context.Background(), l)
|
||||
path, err := h.ensure(context.Background(), android)
|
||||
require.NoError(t, err)
|
||||
got, _ := os.ReadFile(path)
|
||||
assert.Equal(t, apk, got)
|
||||
|
||||
// Подменённый файл не пройдёт сверку.
|
||||
require.NoError(t, os.Remove(path))
|
||||
l.SHA256 = "00" + l.SHA256[2:]
|
||||
_, err = h.ensure(context.Background(), l)
|
||||
android.SHA256 = "00" + android.SHA256[2:]
|
||||
_, err = h.ensure(context.Background(), android)
|
||||
assert.ErrorContains(t, err, "checksum mismatch")
|
||||
_, statErr := os.Stat(filepath.Join(dir, "pipu-music-0.6.1.apk"))
|
||||
assert.True(t, os.IsNotExist(statErr))
|
||||
|
||||
@@ -566,5 +566,6 @@ func TestPublicRoutes(t *testing.T) {
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/server-info", nil).Code)
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/healthz", nil).Code)
|
||||
assert.NotEqual(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/app/android/latest", nil).Code, "APK page is public")
|
||||
assert.NotEqual(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/app/clients", nil).Code, "download page is public")
|
||||
assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/unknown", nil).Code)
|
||||
}
|
||||
@@ -55,8 +55,9 @@ var publicRoutes = map[string]bool{
|
||||
"GET /api/v1/server-info": true,
|
||||
"POST /api/v1/auth/login": true,
|
||||
"POST /api/v1/auth/refresh": true,
|
||||
// Android-приложение раздаётся по ссылке /android без входа: в APK нет ничего, кроме клиента.
|
||||
// Клиенты раздаются по ссылке /download без входа: в файлах нет ничего, кроме приложения.
|
||||
"GET /api/v1/app/android/latest": true,
|
||||
"GET /api/v1/app/clients": true,
|
||||
}
|
||||
|
||||
// Что разрешено, пока пользователь обязан сменить учётные данные / пароль.
|
||||
@@ -93,7 +94,8 @@ func Middleware(svc *Service, log *slog.Logger) func(http.Handler) http.Handler
|
||||
// Аудио открывает <audio> — тоже без заголовка; ссылка подписана для пользователя.
|
||||
cover := (r.Method == http.MethodGet || r.Method == http.MethodHead) &&
|
||||
(strings.HasPrefix(r.URL.Path, "/api/v1/covers/") || strings.HasPrefix(r.URL.Path, "/api/v1/stream/") ||
|
||||
strings.HasPrefix(r.URL.Path, "/api/v1/app/android/download/"))
|
||||
strings.HasPrefix(r.URL.Path, "/api/v1/app/android/download/") ||
|
||||
strings.HasPrefix(r.URL.Path, "/api/v1/app/download/"))
|
||||
if !strings.HasPrefix(r.URL.Path, "/api/v1/") || publicRoutes[route] || cover {
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
return
|
||||
|
||||
Reference in new issue
Block a user