Compare commits

...
4 Commits
Author SHA1 Message Date
mrleo1nidandCursor eb8031b126 ci(release): cross-compile Windows NSIS on the Linux runner
ci / ts / ts (packages + web) (push) Failing after 1m41s
release / release (версия и заметки) (push) Successful in 7s
release / android (APK в релиз) (push) Successful in 6m0s
ci / desktop / rust (desktop proxy) (push) Successful in 10m12s
release / desktop (Linux и Windows в релиз) (push) Successful in 20m8s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 00:43:16 +03:00
mrleo1nidandCursor e4b841a8d8 ci(release): attach Linux and Windows desktop installers
ci / ts / ts (packages + web) (push) Failing after 1m42s
release / android (APK в релиз) (push) Successful in 7m49s
release / release (версия и заметки) (push) Successful in 8s
ci / desktop / rust (desktop proxy) (push) Successful in 7m35s
release / desktop (Linux в релиз) (push) Canceled after 2m48s
release / desktop (Windows в релиз) (push) Canceled after 0s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 00:32:33 +03:00
mrleo1nidandCursor 597067689a feat: add a platform-aware apps download page
ci / api / api (push) Successful in 48s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m10s
ci / db / db (push) Successful in 57s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m1s
ci / desktop / rust (desktop proxy) (push) Successful in 5m37s
ci / ts / ts (packages + web) (push) Successful in 4m10s
release / release (версия и заметки) (push) Successful in 11s
release / android (APK в релиз) (push) Successful in 4m22s
Serve desktop and Android builds from the latest Gitea release on /download, and fix the desktop compile and CI flakes that blocked v1.3.0.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 00:10:03 +03:00
mrleo1nidandCursor efe78d7e07 feat(desktop): wrap the web client in a Tauri app
ci / api / api (push) Successful in 43s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m19s
ci / desktop / rust (desktop proxy) (push) Failing after 3m34s
ci / ts / ts (packages + web) (push) Failing after 2m49s
release / release (версия и заметки) (push) Successful in 5s
ci / db / db (push) Successful in 40s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m12s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m28s
release / android (APK в релиз) (push) Failing after 2m0s
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 23:45:03 +03:00
60 changed files with 2492 additions and 414 deletions

No files matched your search

+33
View File
@@ -0,0 +1,33 @@
# Rust-часть десктопа: прокси и проверки URL. Установщики — в release.yml
# (Linux AppImage/deb и Windows NSIS через cargo-xwin на ubuntu-latest).
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
+5 -1
View File
@@ -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
+137 -5
View File
@@ -1,6 +1,7 @@
# Релиз по тегу vMAJOR.MINOR.PATCH[-rc.N]: Gitea Release с заметками → APK.
# Релиз создаётся сразу (статус Android — «собирается»); сборка APK докладывает в него файлы
# и переписывает статус: собран / не удалась (scripts/release.mjs).
# Релиз по тегу vMAJOR.MINOR.PATCH[-rc.N]: Gitea Release с заметками → APK, Linux и Windows.
# Релиз создаётся сразу (статусы Android/десктоп — «собирается»); сборки докладывают файлы
# и переписывают статус: собран / не удалась (scripts/release.mjs).
# Один Ubuntu-раннер: сначала APK, потом десктоп (Linux native + Windows через cargo-xwin).
# Docker-образы не публикуются: сервер собирает их сам из клонированного репозитория (deploy/update.sh).
#
# Секреты (Settings → Actions → Secrets):
@@ -132,9 +133,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
@@ -196,3 +198,133 @@ jobs:
- name: Статус в релизе — сборка отменена
if: cancelled()
run: node scripts/release.mjs android cancelled
# После Android: на одном хосте параллельный Tauri + Gradle валит раннер.
# Windows NSIS — cargo-xwin + makensis на этом же Ubuntu (виндового раннера нет).
desktop:
name: desktop (Linux и Windows в релиз)
needs: [release, android]
if: always() && needs.release.result == 'success'
timeout-minutes: 90
runs-on: ubuntu-latest
defaults:
run:
shell: bash
env:
PIPU_VERSION: ${{ needs.release.outputs.version }}
GITEA_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
PUBLIC_URL: ${{ vars.PUBLIC_URL || 'https://gitea.hsrv.site' }}
TAG: ${{ github.ref_name }}
VERSION: ${{ needs.release.outputs.version }}
APPIMAGE_EXTRACT_AND_RUN: "1"
CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse
CARGO_BUILD_JOBS: "2"
XWIN_CACHE_DIR: /opt/ci-cache/xwin
steps:
- uses: actions/checkout@v4
- name: Статус в релизе — собирается
run: |
node scripts/release.mjs desktop linux building
node scripts/release.mjs desktop windows building
- 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 build-essential curl wget file \
libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev \
libdbus-1-dev libsecret-1-dev patchelf libfuse2 \
nsis lld llvm clang
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version: "${{ env.NODE_VERSION }}", cache: pnpm }
- uses: actions/cache@v4
with:
path: |
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
~/.cargo/bin/cargo-xwin
key: cargo-desktop-${{ hashFiles('apps/desktop/src-tauri/Cargo.toml') }}
restore-keys: cargo-desktop-
- run: pnpm install --frozen-lockfile
- name: Сборка AppImage и deb
id: linux
working-directory: apps/desktop
run: |
set -euo pipefail
pnpm bundle:linux
bundle=src-tauri/target/release/bundle
shopt -s nullglob
for f in "$bundle"/appimage/*.AppImage; do
cp "$f" "$GITHUB_WORKSPACE/pipu-music-${PIPU_VERSION}-linux.AppImage"
done
for f in "$bundle"/deb/*.deb; do
cp "$f" "$GITHUB_WORKSPACE/pipu-music-${PIPU_VERSION}-linux.deb"
done
ls -lh "$GITHUB_WORKSPACE"/pipu-music-${PIPU_VERSION}-linux.*
rm -rf src-tauri/target/release
- name: Linux в релиз
id: linux-upload
run: |
set -euo pipefail
files=()
for f in pipu-music-${PIPU_VERSION}-linux.AppImage pipu-music-${PIPU_VERSION}-linux.deb; do
[ -f "$f" ] && files+=("$f")
done
if [ ${#files[@]} -eq 0 ]; then
echo "::error::нет AppImage и deb после сборки"
exit 1
fi
node scripts/release.mjs desktop linux success "${files[@]}"
- name: Windows toolchain
if: ${{ !cancelled() }}
run: |
set -euo pipefail
rustup target add x86_64-pc-windows-msvc
if ! command -v cargo-xwin >/dev/null; then
cargo install --locked cargo-xwin
fi
if [ ! -d /opt/ci-cache ]; then
echo "XWIN_CACHE_DIR=$HOME/.cache/xwin" >> "$GITHUB_ENV"
mkdir -p "$HOME/.cache/xwin"
else
mkdir -p /opt/ci-cache/xwin
fi
- name: Сборка NSIS
id: win
if: ${{ !cancelled() }}
working-directory: apps/desktop
run: |
set -euo pipefail
pnpm bundle:windows:cross
bundle=src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis
shopt -s nullglob
copied=
for f in "$bundle"/*-setup.exe; do
cp "$f" "$GITHUB_WORKSPACE/pipu-music-${PIPU_VERSION}-windows-setup.exe"
copied=1
done
if [ -z "$copied" ]; then
echo "::error::нет NSIS setup.exe после сборки в $bundle"
ls -la "$bundle" || true
exit 1
fi
ls -lh "$GITHUB_WORKSPACE/pipu-music-${PIPU_VERSION}-windows-setup.exe"
- name: Windows в релиз
id: win-upload
if: ${{ !cancelled() && steps.win.outcome == 'success' }}
run: node scripts/release.mjs desktop windows success "pipu-music-${PIPU_VERSION}-windows-setup.exe"
- name: Статус Linux — сборка не удалась
if: failure() && steps.linux-upload.outcome != 'success'
run: node scripts/release.mjs desktop linux failure
- name: Статус Windows — сборка не удалась
if: failure() && steps.win-upload.outcome != 'success'
run: node scripts/release.mjs desktop windows failure
- name: Статус в релизе — сборка отменена
if: cancelled()
run: |
node scripts/release.mjs desktop linux cancelled
node scripts/release.mjs desktop windows cancelled
+2 -1
View File
@@ -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
View File
@@ -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]
+19
View File
@@ -0,0 +1,19 @@
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 = process.env.PIPU_VERSION ?? '0.0.0';
if (!process.env.PIPU_VERSION) {
try {
const tag = execFileSync('git', ['describe', '--tags', '--abbrev=0'], { encoding: 'utf8' }).trim();
version = tag.replace(/^v/, '');
} 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}`);
+18
View File
@@ -0,0 +1,18 @@
{
"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",
"bundle:linux": "node inject-version.mjs && tauri build --config src-tauri/tauri.release.json --bundles appimage,deb",
"bundle:windows": "node inject-version.mjs && tauri build --config src-tauri/tauri.release.json --bundles nsis",
"bundle:windows:cross": "node inject-version.mjs && tauri build --config src-tauri/tauri.release.json --runner cargo-xwin --target x86_64-pc-windows-msvc --bundles nsis",
"tauri": "tauri",
"icons": "tauri icon ../web/public/favicon.svg"
},
"devDependencies": {
"@tauri-apps/cli": "^2.9.0"
}
}
+42
View File
@@ -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
+3
View File
@@ -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

+157
View File
@@ -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");
}
+5
View File
@@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
pipu_desktop_lib::run();
}
+62
View File
@@ -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:?}"))
}
}
+170
View File
@@ -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");
}
}
+80
View File
@@ -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()),
},
}
}
+31
View File
@@ -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 } }
}
}
+40 -9
View File
@@ -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;
+28
View File
@@ -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__;
});
});
+94
View File
@@ -0,0 +1,94 @@
import type { AuthUser, RefreshStore } from '@pipu/core';
import { createContext, createElement, useContext, type ReactNode } from 'react';
type Tauri = {
core: { invoke: (cmd: string, args?: Record<string, unknown>) => Promise<unknown> };
event: { listen: (event: string, handler: (e: { payload: string }) => void) => Promise<() => void> };
};
function api(): Tauri | undefined {
return (window as Window & { __TAURI__?: Tauri }).__TAURI__;
}
export function isDesktop(): boolean {
return api() !== undefined;
}
function invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
const tauri = api();
if (!tauri) throw new Error('Tauri API unavailable');
return tauri.core.invoke(cmd, args) as Promise<T>;
}
export type DesktopHandle = {
proxyBase: string;
server: string | null;
setServer: (url: string) => Promise<string>;
clearServer: () => Promise<void>;
refreshStore: RefreshStore;
listenMedia: (handler: (action: string) => void) => Promise<() => void>;
setMedia: (info: { title: string; artist: string; album: string; coverUrl?: string | null; playing: boolean }) => void;
};
export const DesktopContext = createContext<DesktopHandle | null>(null);
export function DesktopProvider({ value, children }: { value: DesktopHandle | null; children: ReactNode }) {
return createElement(DesktopContext.Provider, { value }, children);
}
export function useDesktop(): DesktopHandle | null {
return useContext(DesktopContext);
}
let active: DesktopHandle | null = null;
export function desktopHandle(): DesktopHandle | null {
return active;
}
export async function createDesktop(): Promise<DesktopHandle | null> {
if (!isDesktop()) return null;
const boot = await invoke<{ proxyBase: string; server: string | null }>('bootstrap');
const handle: DesktopHandle = {
proxyBase: boot.proxyBase,
server: boot.server,
setServer: async (url) => {
const server = await invoke<string>('set_server', { url });
handle.server = server;
return server;
},
clearServer: async () => {
await invoke('clear_server');
handle.server = null;
},
refreshStore: {
get: () => invoke<string | null>('token_get'),
set: (token) => invoke('token_set', { token }),
clear: async () => {
await invoke('token_clear');
await invoke('user_clear');
},
getUser: async () => {
const json = await invoke<string | null>('user_get');
return json ? (JSON.parse(json) as AuthUser) : null;
},
setUser: (user) => invoke('user_set', { json: JSON.stringify(user) }),
},
listenMedia: async (handler) => {
const tauri = api();
if (!tauri) return () => undefined;
return tauri.event.listen('media', (e) => handler(e.payload));
},
setMedia: (info) => {
void invoke('media_set', {
title: info.title,
artist: info.artist,
album: info.album,
coverUrl: info.coverUrl ?? null,
playing: info.playing,
});
},
};
active = handle;
return handle;
}
+11 -2
View File
@@ -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}>
<DesktopProvider value={desktop}>
<App session={session} router={router} />
</DesktopProvider>
</I18nextProvider>
</StrictMode>,
);
+55 -20
View File
@@ -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,26 +30,26 @@ 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>
{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}>
<li>{t('app.step_install')}</li>
{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')}>
@@ -55,12 +57,32 @@ export function AppDownloadPage() {
<Icon src={copied ? checkIcon : copyIcon} size={16} />
</button>
</li>
<li>{t('app.step_updates')}</li>
{detected && <li>{t(`app.step_updates.${detected}`)}</li>}
</ol>
)}
{primary?.sha256 && (
<details className={s.details}>
<summary>{t('app.checksum')}</summary>
<code>{r.sha256}</code>
<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>
);
}
+55
View File
@@ -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>
);
}
+9 -1
View File
@@ -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
}
+17
View File
@@ -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>
+35 -1
View File
@@ -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;
}
+36
View File
@@ -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();
}
}
+17
View File
@@ -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']);
});
});
+17
View File
@@ -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);
}
+22
View File
@@ -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 -2
View File
@@ -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
View File
@@ -5,7 +5,7 @@
## Суть
Закрытое (self-hosted) музыкальное приложение для прослушивания музыки с собственного сервера.
Есть веб-версия и мобильное приложение для Android. Главная фишка — **«Моя волна»**: бесконечный умный поток
Есть веб-версия, десктоп и мобильное приложение для Android. Главная фишка — **«Моя волна»**: бесконечный умный поток
музыки, который подстраивается под вкус пользователя и под выбранное настроение или занятие.
Масштаб: до ~100 пользователей, библиотека от 11 000 треков и больше.
@@ -16,7 +16,7 @@
## Доступ и учётные записи
- При первом открытии приложение показывает **экран входа** (логин + пароль).
В мобильном приложении на этом экране также указывается **адрес сервера**.
В мобильном приложении и на десктопе на этом экране также указывается **адрес сервера**.
- **Регистрации нет.** Учётные записи создаёт администрация.
- Роли:
- **Администратор** — полный доступ; единственный, кто может создавать и назначать модераторов
+1
View File
@@ -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 |
+4 -2
View File
@@ -102,8 +102,10 @@
прочитайте заметки к релизу на странице релизов Gitea.
Android-приложение обновляется само: при запуске оно сверяется с сервером и предлагает новый APK.
Раздать приложение — ссылка `https://<ваш-домен>/android` (без входа): кнопка «Скачать APK» последнего релиза,
адрес сервера для копирования и короткая инструкция; та же страница — внизу экрана входа и в меню профиля.
Раздать приложение — ссылка `https://<ваш-домен>/download` (без входа; `/android` перенаправляет сюда):
сборка под систему посетителя, остальные платформы ниже, адрес сервера для копирования и короткая инструкция.
APK, Linux (AppImage, deb) и Windows (NSIS) прикладываются к релизу Gitea автоматически. Та же страница — внизу экрана входа
и в меню профиля.
## 7. Наблюдение
+2
View File
@@ -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) |
+3 -2
View File
@@ -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
View File
@@ -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` | |
+17
View File
@@ -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.
Релиз по тегу собирает всё на Linux-раннере: AppImage/`.deb` и Windows NSIS через `cargo-xwin` (`pipu-music-<версия>-windows-setup.exe`). Подпись Windows-установщика на Linux нет. Страница `/download` на сервере отдаёт те файлы, что есть в последнем релизе Gitea.
+1 -1
View File
@@ -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
View File
@@ -99,7 +99,7 @@
## Самообновление
Первая установка — публичная страница `/android` веб-клиента: последний APK, адрес сервера и инструкция.
Первая установка — публичная страница `/download` веб-клиента (`/android` ведёт туда же): APK, десктопные сборки по ОС, адрес сервера и инструкция.
Приложение распространяется только через Gitea Releases (не Google Play).
+1 -1
View File
@@ -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;
+31 -7
View File
@@ -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"
}
}
+1 -1
View File
@@ -41,7 +41,7 @@
"menu": "Profile menu",
"settings": "Settings",
"logout": "Sign out",
"android_app": "Android app"
"download_apps": "Download apps"
},
"roles": {
"admin": "Administrator",
+31 -7
View File
@@ -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": "Войти в веб-версию"
}
}
+1 -1
View File
@@ -41,7 +41,7 @@
"menu": "Меню профиля",
"settings": "Настройки",
"logout": "Выйти",
"android_app": "Приложение для Android"
"download_apps": "Скачать приложения"
},
"roles": {
"admin": "Администратор",
+133 -4
View File
@@ -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
+100 -15
View File
@@ -11,7 +11,7 @@
import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { existsSync, readFileSync } from 'node:fs';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { basename, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -29,6 +29,12 @@ const COMMIT_RE = /^(\w+)(?:\(([^)]+)\))?(!)?:\s*(.+)$/;
export const STATUS_START = '<!-- android-status -->';
export const STATUS_END = '<!-- /android-status -->';
export const DESKTOP_PLATFORMS = ['linux', 'windows'];
export function desktopStatusMarks(platform) {
if (!DESKTOP_PLATFORMS.includes(platform)) throw new Error(`unknown desktop platform: ${platform}`);
return { start: `<!-- desktop-${platform}-status -->`, end: `<!-- /desktop-${platform}-status -->` };
}
/** Раскладывает коммиты (Conventional Commits) по разделам заметок. */
export function groupCommits(commits) {
@@ -76,16 +82,62 @@ export function renderAndroidStatus({ status, runUrl, versionCode, apk, signing
return [STATUS_START, ...lines.map((l) => `> ${l}`), STATUS_END].join('\n');
}
/** Заменяет блок статуса в тексте релиза (или дописывает его в начало, если блока нет). */
export function replaceAndroidStatus(body, block) {
const start = body.indexOf(STATUS_START);
const end = body.indexOf(STATUS_END);
if (start === -1 || end === -1 || end < start) return `${block}\n\n${body}`;
return body.slice(0, start) + block + body.slice(end + STATUS_END.length);
/** Блок статуса сборки десктопа в тексте релиза (отдельный для linux и windows). */
export function renderDesktopStatus({ platform, status, runUrl, files }) {
const label = platform === 'windows' ? 'Windows' : 'Linux';
const pending = platform === 'windows' ? 'NSIS-установщик' : 'AppImage и .deb';
const lines =
status === 'success'
? [
`✅ **Десктоп (${label}):** собрано — ${files.map((f) => `\`${f.name}\` (${formatSize(f.size)})`).join(', ')}.`,
]
: status === 'cancelled'
? [
`⏹ **Десктоп (${label}):** сборка отменена — [лог сборки](${runUrl}).`,
'Файлы появятся здесь после перезапуска сборки.',
]
: status === 'failure'
? [
`❌ **Десктоп (${label}):** сборка не удалась — [лог сборки](${runUrl}).`,
'Остальные клиенты от этого не зависят; установщик появится после перезапуска сборки.',
]
: [
`⏳ **Десктоп (${label}):** собирается — [ход сборки](${runUrl}).`,
`${pending} появится в этом релизе автоматически.`,
];
const { start, end } = desktopStatusMarks(platform);
return [start, ...lines.map((l) => `> ${l}`), end].join('\n');
}
/** Текст релиза: статус Android, изменения, обновление сервера, установка APK. */
export function renderNotes({ version, tag, prevTag, prerelease, repoUrl, groups, androidStatus }) {
function replaceBlock(body, startMark, endMark, block) {
const start = body.indexOf(startMark);
const end = body.indexOf(endMark);
if (start === -1 || end === -1 || end < start) return `${block}\n\n${body}`;
return body.slice(0, start) + block + body.slice(end + endMark.length);
}
/** Заменяет блок статуса Android в тексте релиза (или дописывает его в начало, если блока нет). */
export function replaceAndroidStatus(body, block) {
return replaceBlock(body, STATUS_START, STATUS_END, block);
}
export function replaceDesktopStatus(body, platform, block) {
const { start, end } = desktopStatusMarks(platform);
return replaceBlock(body, start, end, block);
}
/** Текст релиза: статусы сборок, изменения, обновление сервера, установка клиентов. */
export function renderNotes({
version,
tag,
prevTag,
prerelease,
repoUrl,
groups,
androidStatus,
desktopLinuxStatus,
desktopWindowsStatus,
}) {
const link = (it) => `([${it.sha.slice(0, 7)}](${repoUrl}/commit/${it.sha}))`;
const item = (it) => `- ${it.scope ? `**${it.scope}:** ` : ''}${it.text} ${link(it)}`;
const out = [];
@@ -93,7 +145,10 @@ export function renderNotes({ version, tag, prevTag, prerelease, repoUrl, groups
if (prerelease) {
out.push('> 🧪 **Предварительная версия** — для проверки перед релизом, не для основной установки.', '');
}
out.push(androidStatus, '', '## Что изменилось', '');
out.push(androidStatus, '');
if (desktopLinuxStatus) out.push(desktopLinuxStatus, '');
if (desktopWindowsStatus) out.push(desktopWindowsStatus, '');
out.push('## Что изменилось', '');
if (groups.breaking.length) {
out.push('### ⚠️ Несовместимые изменения', '', ...groups.breaking.map(item), '');
}
@@ -124,11 +179,14 @@ export function renderNotes({ version, tag, prevTag, prerelease, repoUrl, groups
'',
`Ожидается \`"version":"${version}"\`.`,
'',
'## Android',
'## Клиенты',
'',
`- **Установка или обновление:** скачайте \`pipu-music-${version}.apk\` ниже и откройте его на телефоне;`,
' Android попросит разрешить установку из этого источника. Обновление ставится поверх, данные сохраняются.',
`- **Проверка файла:** \`sha256sum pipu-music-${version}.apk\` должен совпасть с \`pipu-music-${version}.apk.sha256\`.`,
'Скачайте с этой страницы или с `/download` на сервере (подскажет сборку под вашу систему).',
'',
`- **Android:** \`pipu-music-${version}.apk\` — откройте на телефоне; Android попросит разрешить установку из этого источника.`,
`- **Windows:** \`pipu-music-${version}-windows-setup.exe\`.`,
`- **Linux:** \`pipu-music-${version}-linux.AppImage\` (сделайте исполняемым) или \`.deb\`.`,
`- **Проверка APK:** \`sha256sum pipu-music-${version}.apk\` должен совпасть с \`pipu-music-${version}.apk.sha256\`.`,
'',
`Первая установка сервера — [инструкция](${repoUrl}/src/tag/${tag}/docs/install-guide.md).`,
'',
@@ -202,6 +260,8 @@ async function create() {
repoUrl,
groups: groupCommits(commitsBetween(prevTag, tag)),
androidStatus: renderAndroidStatus({ status: 'building', runUrl }),
desktopLinuxStatus: renderDesktopStatus({ platform: 'linux', status: 'building', runUrl }),
desktopWindowsStatus: renderDesktopStatus({ platform: 'windows', status: 'building', runUrl }),
});
const fields = { name: `Pipu Music ${env('VERSION')}`, body, prerelease, draft: false };
const saved = release
@@ -242,13 +302,38 @@ async function android(status, apkPath) {
console.log(`статус Android: ${status}`);
}
async function desktop(platform, status, paths) {
const { release, runUrl } = await context();
if (!release) throw new Error(`релиз для ${env('TAG')} не найден — сначала node scripts/release.mjs create`);
const files = [];
if (status === 'success') {
if (!paths.length) throw new Error('desktop success: укажите файлы');
for (const file of paths) {
const data = readFileSync(file);
files.push({ name: basename(file), size: data.length });
const sum = `${file}.sha256`;
if (!existsSync(sum)) writeFileSync(sum, `${createHash('sha256').update(data).digest('hex')}\n`);
await upload(release, file);
if (existsSync(sum)) await upload(release, sum);
}
}
const block = renderDesktopStatus({ platform, status, runUrl, files });
await api('PATCH', `/releases/${release.id}`, { json: { body: replaceDesktopStatus(release.body ?? '', platform, block) } });
console.log(`статус десктопа (${platform}): ${status}`);
}
async function main() {
const [cmd, ...args] = process.argv.slice(2);
if (cmd === 'create') return create();
if (cmd === 'android' && ['building', 'success', 'failure', 'cancelled'].includes(args[0])) {
return android(args[0], args[1]);
}
throw new Error('использование: release.mjs create | android building|failure|cancelled | android success <file.apk>');
if (cmd === 'desktop' && DESKTOP_PLATFORMS.includes(args[0]) && ['building', 'success', 'failure', 'cancelled'].includes(args[1])) {
return desktop(args[0], args[1], args.slice(2));
}
throw new Error(
'использование: release.mjs create | android building|failure|cancelled | android success <file.apk> | desktop linux|windows building|failure|cancelled | desktop linux|windows success <file...>',
);
}
if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
+35
View File
@@ -4,8 +4,11 @@ import { test } from 'node:test';
import {
groupCommits,
renderAndroidStatus,
renderDesktopStatus,
renderNotes,
replaceAndroidStatus,
replaceDesktopStatus,
desktopStatusMarks,
STATUS_END,
STATUS_START,
} from './release.mjs';
@@ -77,6 +80,34 @@ test('adds a status block when the body has none', () => {
assert.ok(replaceAndroidStatus('hand-written notes', block).startsWith(block));
});
test('desktop status blocks', () => {
const linuxMarks = desktopStatusMarks('linux');
const winMarks = desktopStatusMarks('windows');
const building = renderDesktopStatus({ platform: 'linux', status: 'building', runUrl: 'https://run' });
assert.ok(building.startsWith(linuxMarks.start) && building.endsWith(linuxMarks.end));
assert.match(building, /Десктоп \(Linux\):.*собирается — \[ход сборки\]\(https:\/\/run\)/);
const ok = renderDesktopStatus({
platform: 'linux',
status: 'success',
files: [{ name: 'pipu-music-1.4.0-linux.AppImage', size: 40 * 1024 * 1024 }],
});
assert.match(ok, /`pipu-music-1\.4\.0-linux\.AppImage` \(40,0 МБ\)/);
assert.equal(replaceDesktopStatus(`a\n\n${building}\n\nb`, 'linux', ok).includes(building), false);
const winBuilding = renderDesktopStatus({ platform: 'windows', status: 'building', runUrl: 'https://run' });
assert.ok(winBuilding.startsWith(winMarks.start) && winBuilding.endsWith(winMarks.end));
const winOk = renderDesktopStatus({
platform: 'windows',
status: 'success',
files: [{ name: 'pipu-music-1.4.0-windows-setup.exe', size: 12 * 1024 * 1024 }],
});
const both = `${building}\n\n${winBuilding}`;
const patched = replaceDesktopStatus(both, 'windows', winOk);
assert.match(patched, /Linux/);
assert.match(patched, /windows-setup\.exe/);
assert.doesNotMatch(patched, /Десктоп \(Windows\):.*собирается/);
});
test('release notes', () => {
const notes = renderNotes({
version: '0.2.0',
@@ -86,12 +117,16 @@ test('release notes', () => {
repoUrl,
groups: groupCommits([{ sha: sha(1), subject: 'feat(web): library screen' }]),
androidStatus: renderAndroidStatus({ status: 'building', runUrl: 'https://run' }),
desktopLinuxStatus: renderDesktopStatus({ platform: 'linux', status: 'building', runUrl: 'https://run' }),
desktopWindowsStatus: renderDesktopStatus({ platform: 'windows', status: 'building', runUrl: 'https://run' }),
});
assert.match(notes, /### Новое\n\n- \*\*web:\*\* library screen \(\[1111111\]\(https:\/\/git\.example\/owner\/pipu-music\/commit\/1{40}\)\)/);
assert.match(notes, /\[v0\.1\.7\.\.\.v0\.2\.0\]\(https:\/\/git\.example\/owner\/pipu-music\/compare\/v0\.1\.7\.\.\.v0\.2\.0\)/);
assert.match(notes, /\.\/update\.sh v0\.2\.0/);
assert.match(notes, /"version":"0\.2\.0"/);
assert.match(notes, /pipu-music-0\.2\.0\.apk/);
assert.match(notes, /pipu-music-0\.2\.0-windows-setup\.exe/);
assert.match(notes, /pipu-music-0\.2\.0-linux\.AppImage/);
assert.match(notes, /src\/tag\/v0\.2\.0\/docs\/install-guide\.md/);
assert.doesNotMatch(notes, /Предварительная версия/);
});
+500 -270
View File
@@ -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{
"H4sIAAAAAAAC/+x9/28cx5Xnv9KY3QPI7IikZMtJJCwOsmzH3rViRpTPd4h0w+ZMkexVT/e4u4cSYwgg",
"xTiyj4q49mZ3g83Gjje32APyy4jiiCOKXwD/Bd3/Qv6SQ71XVV3VXdXdQ/ZQ9mKBIBZneqqrXr169b5+",
"3ieNtt/t+R7xorBx5ZNGzw7sLolIAH9d7wehH9B/dUjYDpxe5Phe40oj/n08jE+Sjfg43k824kHyKD5K",
"tuMXVnyQbCUbyWZ8nGxY8SjetxY9cj9qtWGgRQt+NIz3ku14L9lKPo+H8Qsr2Uwe0lHio3iU/CrZbjQb",
"Dn3Lx30SrDeaDc/uksaVBo7RaDbC9irp2nRW0XqPfhNGgeOtNB48aDbe9iInWn+vQ7+FQXp2tJqO4XQa",
"zUZAPu47Aek0rkRBn8jjLftB144aVxr9PjyZH/99p+tEYvDMDF34Uh6wQ5btvhs1rlyeaza69n2n2+82",
"rlyao385Hv51UbzH8SKyQgJ40YLfD9pkcgv5sOf6dmchsqN++I7jRkS3zd8kD+NB8jDZSjaTbSt5FA9h",
"+/ateD8exCfJDnz3pNFskPs91++QxpVl2w2JfgNDeJlCHyciXeC0vwzIcuNK4y9mU3acxcfCWXmqjQdi",
"MXYQ2Ov07zBad+kHdNH07w9DEkyKcA/oj8Oe74UE5j0f+Esu6dJ/tn0vIh4wh93ruU7bpmSc7eETf/V3",
"IaXpJ9K7itbMx4U3Znblq+SzeBQ/jQ/igTV1853r1o9fv/zDadhV9nM6+rVO1/EoKeBYB36PBJGDk24H",
"xI5Ip2VHypI7dkQuRE6X5NfdbHScsOfa6y0k4ycNr++69hKlO5Iw9wOnU4GezYYTtpZcv32XdKQDveT7",
"LrE9+r1rh1HL9Vccr2i6pbNx/bbtEo3MaDa6/TBqtVdtb4W02gHpEC9ybDfUT0d+uGeH4T0/MEw8IGsO",
"uRe2+sC9oeZ0/Wt8HD+Ph8lDC2ToZrIZH8Jx24hH8S7867EVH4tjt0f/PIoHVrwXH8dP40G8G7+Mh1Rs",
"xsMZiwplK9mEj/aSLRgYRDDI6ZfxyKL/OxQvjZ8m2/CKZIsOO0w24wP6YPI4PohHyadW/DJ5Eu9xEU0F",
"9H6yle6hvFYfaVvE0TfpMw+ajX5IAs5EebGUnsyf41EVj2dYkL00T2ex1wpvGXbOvPtN+ZRk2fCOIIK/",
"9HekHdF1ifM2b6+Q/JkTkq6SyEsPr0beSTdqhZOYJSq8Xh1Euxx3qd/NL8MOIieMSqcPT90ky3Sktr9G",
"glbbd/1AJUDuJGYXir/sB24lgdPpByBxW8ro4k6tLJEiJzJIiiiw23erb+It+rhuXevELto5WQ3InQec",
"HRtDJlGG0E2+VSphxCKMe053LX9jnNMennmLzoO0RtIt9LtdO1j/Xp2aeg5Fq+33UfXJH7tz5nZ5PmU7",
"9T7blTOJannnc9uhlb7aaXm2ux46IVNz8yxEv/8FCVprJKgmDn2P6Ddk2XZcRd2Svvu4T/qm74K+59HB",
"tV9Gfq9FgkBrLX4TD0Cz2aEmBFV0HsIf8bGkyIKGMYz3qI5ClZGteI8qOPSJ5NNkM9mx/rzxGys+iY/j",
"PdBUBvE+/f+mlTxKHsbHdOgRWJAPk8flGmFmX9iy0zUy+gliNdUNkNer381O4Dudm8QldqjRBjr+PY+q",
"K/yY5pVSx2uF/V7PDyLcjgxJv6Q6YbIZj5BeI6rTWVQrTHbANjtKtuHP+Cg+jneRKpSKjaZm7zw/ImGr",
"q3vPfwBt2SjH8bP42Jq6YQd36QKEdjmk2iV97bROdISr9qXLb2hXGTq/IIr4cbzojde1c1wjQeigBUXu",
"290eWHxzM2/MXNS9kz3daoNN+kmZrOGDZ34oUYbNVaymqW5hdsO0PCEugczJpgKkSIBWFNEGpbpUQuvk",
"LlOx5alVkKzi/sqL1DMtwThD7Sz6HSe61o784NTTOK2JYpzO216kVQvaUY6l6WAzaHropmbzhdmu+8Fy",
"48rPSy6nlBgP7mQdCVREglSlcgSk8pCKaXqsqbXIRPDL5Iv4iLv3TpiBuA/yYAA/eamRtw+ap3IyVNyf",
"nr1OTx6QodNx6HJsd14iLYp8wxWQ7k1kByskaunfWnrBsl/j52ObYXClwMY0OR+oQ8rTS5es0NXIb7WY",
"oCnjvjIbtB+t3vLvEk+nD7XbJAxbEf1aK/XI/Z4TkLDleFrH5kZ8HB9Y8XO4uI7ikYUDXqD3XXwAXhWq",
"W2zGw/gg2YqP4j1wTGu0IrIckHA1nUnmVb+JXyY71mLbdYiHW3vFslE9WLTiUeabDgnvRn5vUauD0zcI",
"fiMetc9/3niT2AGR6afKsbJtvkFy26OQVnmtQlU2vm7j3vT9KIwCu3c99ajcJB/3ie7+88g9nStNuhXI",
"vVZ1kaw83VRH1831Ovh+5tkjxlm2+0FAt6l0pgUPaGZaPDXgjFuZDb9HlkAdBRZqgFCnLKPlgOsgLD4M",
"SWBcWNax27Xvv0+8lWi1ceWN1zVDlvs1v+G+QiveTbbpJYKezAHcKKPkIYYPniUbyRbcJAczluQMhS/5",
"j+A4okvy2ILPD6maf45eSImX4AXajUKZrPe2R6Tb8wM7WFdYI0Ozr0HqUKpt04uVEgKMHmru0AVbSNPG",
"KU+55E/ULI+Omp+kbqFvrxEvetOO2qv5dZI1HkSsdL/Mu/Y6DAfmjn3/PfzR5bm5Ehuavad4ejdJCHE3",
"3bXRi0wWbqePcRti8CAGhL5Ja5B9Q3Wm5DNwoR9azMbdSLbjoTV1uz839xqxXpuz4j1q3MYvpoWStQnm",
"LtxCu/EQfkd1rUML4gBDHK9R6i0R61IWIc1YR653HOJ23uZGe9bj19FbE8v0R+XHBh9r4ji6l79LbDda",
"Nbk7QvE5F3v+XY2Ey7yU/Ur7OieM/GCdctmp7QIqJ8fUaMFmqugo1qmI6Sv5WAWLq0Ptk+n0qvS+d/0w",
"essJ9N65jjOuQxQCwKUMy8LEMLxuUu87S1Q+Go6LcH1pzotLWhUZLCBuyzDbZiOEjIBqQ+UOI85BHkR6",
"W7Nh9mTJy66DvxQyvioGY5Mw+lrdpb4piGQzP23pjav6cx9wD7VhWNgh2IWCBwxfdZ0wdLyVVsEjuO3V",
"dwmzT3DuJlGQRsPy77tnr5WqYh/Za+QGiQKnHeavM0arJt8L8TpOieyyVRKmK5Z2jE1LyxD+iuOZdf/U",
"QitblKSyQ+LGmtMmQrXOJVENIB9qFB/Gw2TD+vZP11cDv0usWesjx+v498JvXzatb/8079wnrvWjb182",
"mrJ2flHSlGTfSIF9ciqtVwqWy4TQkfEG+W4nm/xXFsh3JgvEafUCslye++XMw2PnmzgiZne2tBHdEfnA",
"W/LtoMPiaLowsSqlJxKHzyaUxU/jl8ARGImDcB0Ge+KnyAzJZ/yDAfAG/Rh9xckmeoUx9gb+4J0q7tNX",
"F9WQfzlmmD9L1RXiBaRow2qavmbmVaaXDpGXTZk3iH83pcuXLa+Yj6+v+k6bmLi55XRU+pTuuOwLqETx",
"0jsxHfG1MueCNOnC5c/b0eq8HUUk8DQacdgiHmV+46WwQu7nZz5Xpt7i75ry+Ia50XnpzacefjmGn0Za",
"acnu5C0qfFXBJOdRCp/VqmDDmFTVvte1o/aq3u1jsCHS35RP36g5TpLazUbIgoZnyTOvvmHCX6e5lbyI",
"Wlwo0kuPI388G8kA7Zxp/HJGT0jsoL0K4tl1CY+VraKjosFyVrTu7+XA75akA6pX4YcfvvfW2g+puvXQ",
"ig8grWEYHyUP4wGmnxxQbSh5RDWqPYiWntBH2TNH8TH470bUGCmTcn4bIgrj6cX+8rLrKGk9klRhriLT",
"cnt+6BSmR+LlUjUfLLN3oF+FkR1EzGnVIh74G+46PdhCchdMiX6IqlfYhxW6zl3Qtzz2j44Tio/4v3Ub",
"S1mlFZIQcjVO4xFJH5EWru6K6RAs2e2715GFTZ7EvOFhLxG3QE/7pKYlGidMTXliPLnVEwqyBHhwRxP6",
"Z2YvTrpUESxlTDzfY+kQWUkJQ7Qcr4P3rs6z3yN21Ooyr7dwOi8vU67wUC65Wl4MV/vLy67hUBYdqlLK",
"9HudMtO5dIw13+0rKqjX7y5pZL90COQN4dRXSShkeCNdvkpD8WKZG5QV3Snj1ve8Xj8qZNmxGDXDluWO",
"FJUtxTU616yHRQtDXjmOLX79d5J7U84TCsnFpm4hdXCkiZtcpgRXl270F2k6by6YW0sJgG6lOq3rjrQK",
"w3FQ9BeFrS/NaflaE+e/eAn1RPG3KZB1FmNOmQ7Sgd7yzsd9wp6A20Nv+MqrlOdStO11JHfnGeK0+d3Z",
"kerxk6qbX1YSUyHN9qxeGWoPhep0cipmQaprhRuvam5olmnEzLIOILU8Rqn6qnBd0T39EB4bX8zgkc4L",
"GZmIY5hv4mcGQSLZyOb4Zbm6ZkerLYjuh+YETLNDMqUeszbTOy4XHjlmgZFkE9JNT1hBwBAckcln8YD5",
"LSFVT2SzQjYfz5sxX1TK9VkQdg1EJkmpoL9BIhvu1LtOr2dy/kT2ypkIl9t0EcRVCZpOQ90zNgOxsKLg",
"r1TdrM8OycUyBlDSIfKFD+g+yIUeIyveg4RI5+KPvCYEMpQY2KLdj1ZnHG/Ndp2O7Flf1Mu/yHZcfTpV",
"spNsQmoNBEb4a4FfnlF2sqbot+LzE56AFQ9Mac5PtOUNadDWWKY9AocCeAzoqEfJdvIpewnEVVg1/UZ8",
"nGzGg0az2t0kZe9okx4CuxuWpUjndjzNu9FIb2aqCmdTw17y+9GVJdf27jYq5uYUZATdJHY7Ip1bPGXm",
"7PfjGbJvlHvAnHkjz7mO9AiFBmdOj2g2Ij+y3TG8nvh8eQYFzJMXLzBzI+PD0Vkb/GcGZTaQRi2lEn0u",
"Lw3ZF4Y5d4w5H+1VwhR7k2BWj/eif3eRJ/EtLtuOu2hNoSRjhWjoMVTzV3e5/IEQ7TDZgNKsTUPhVEae",
"/CEexvvJFkTjAJbDYhAsuzASiBkqwZ5DYJhJFRyfStfUN1lw6KVkO/r+lcDuKA7w0rONVNRTH/Lkja7y",
"kjx6VlFnxSOeI3/VoiuPn1JJykLj8SD5InlopTn8lORt37/rkGruspsshM3pYHe6kOlOLevAxmqNTNZ7",
"ul8LbdubD/yVgIS6pKJOx5R2Wl35Ks4Q8pxw9YzuomW/7xlmudx3XUM6BGbiGLKT/DXTunuB3yZhWL3e",
"VHon+JrHvQ78FntlofVRQVymZaJAFWU66k5wkipvl5feZJyRvp7TLCWs2PgiZY2yn/F08c3L6UlwcqjI",
"YlA/j614l8omK/llPIhfxC+T7aZFdSKqLKnSDIpCIcMZskrifarDxUeiXmyoyRLRHboFiO9gxnZhNl4N",
"9dhNbZJF5ar8fFZFEEB+dQuxjvIU/udUtQP5vwdVD89AQmPCD8SZBvEelVhNCzJwIE3nhNJ4lGwmn4Ix",
"Q6l9iEk69IpBIwhV2CNQu1+CiH9c5ZSfSovQ67i/Ag37gHLIcfwUEL2esWpsWMhAtt72mDr+Ak9A1ZTL",
"osRJcxZi5ZRCbSbFJPx8RVmNjMR5lirXxRZIsEaC97xlX3N4ek5LWzq9pq2bXuKFWy05dUQrNHbRwttF",
"9pRqc7CWFHj7YXxM/0uZ+vPkC8atqJYMk43kMyoqRGCVx1qV7DTJLEPhlAEAMKSidR2vxSqkWtla8JzN",
"CnOOD1lmE1qu7EfX/Q6xmOpxgRmqmCkFjJzsNMHGZQs9psuDCVJOTzagupBV86QK3zDZgEI6thMX5+Z+",
"/GNtVT7zsaV71nN6/Qvdfui0tXX2vPq8hXlsofJjemfR14JqXD1tTOIdEwJBsqOsLR5YUwuk+z9IMC0v",
"szE3c3FmrtRKZK67tCBfZmDdGgv2WsvO+gMU8kXWlPirGqtZyoGyepjsiNMDRn81HYbVIWorkIaQPYpc",
"OYr3VdZ8QXcJXBDxgCrGks9Be4Qyucu1peDaYdTqh2f28crTUxOTdchd/IUp/Qr4oI7AAWep0wcMMAXf",
"VMFWlFH8NYgpLtNYAjCmqmzwbGA5oRghRPQJxdbUT99ZQIl8RM1H0F5EceKAmrLwxXF8KKQ8Hw44DG6D",
"x9NaFpsgRADseti2a0ANFK5hTcnmHqg1J0z/4TlAIhH2Mf3bev+9N29eu/m/Wu9+sHCrSY32LTDFj/Hm",
"w6q/x2C7Ux0cPQSVtDhTEMZbDlvkPhWUlU4ufdyHZYXG4ULQMSoNR6lePRaiGM3abBLYRXovBmu22+o6",
"Xj/SOWTiv493kRGBjqAjg54wQtFHFYYjKUyQ8cxQxgYYW8QzLQ8apE6TqoU06a9aXRKGzFtYAWdipdUL",
"HD9wovVSPdRemeePSh7b8hnyi+te4ESkFZFuz2UhrQyV/18adUFWfojnHUzB5FNhNqKLveO3w1kmqWa6",
"nb/44bQaqBmKau+iUE2FWB+T/BKrKsdAZXLlYGcIbOK3Zk7w5ojVlBBu1W3OyKPMDQUHxnwFGHylmntA",
"g56qCLBKuQD6pAFFopxRgmglhvaEi+Dn5Son8hUfFImIl8tzuWW+Ne99LaoIKhJn1UTm9VX/5erIN+gW",
"wrgaqhxoI7FkWqaj/PSdhQvJpxwHHKrpP6f6R3APMFmSR3jJpirHQK9USJn36kQAmRoFD72p+dtHWP2k",
"ebOowB+xen4m5na4+nME2hM41pOt+ISajtNNi7KnBFaXfdEmA1MbaGf/PT+wKsXnTNSGQH7ZXd2o+YzX",
"erVdzepxyVb8FH0eyWOswmPmMPoTIAgDXBsfINwW/Grx9UuXLIh1QwrKDJ9di0XAF297yaa1iMHcmYDY",
"oe8tzlig4qeBH+Z7Q4+LFOthN6xABZS00gEPfANWP2d+5tTAbRtSXf4kj0tiLQIlL/C5zkLQZ3HmtqfW",
"pxqEoEG8LORiUD3iddD53fX7HjrG0fsNMcZs/YsUh8kWL58W76EeI8KopteuKJ+PRlqghVXWf0pUnlsk",
"jIwILv2O46e17rnL5kABDELIofhYUUsh6+QF80Yfs7itepyEwEdreKSC8tDzc3Fubm5aK6MkUuYrOO7q",
"FTUUTuOYBfgLHC9DhD8icB/GA9D/vp/CMKXIfkwaL86AY5Gf6jBapHefTEYUFZmr/qqFcW4qvEGpnzaf",
"BlPVIcSZUy6R97aAOzK1J96yUOn18iAKiN39Wd922aUhorv9yG80G37grDgepD2sOiurMKOOAzVIrn9P",
"O6R8tUgDRvZK2Fp2AihbYklX9A/tEBxxaV4qIK+CHFV8HCsCORmSbGyO+l4aYWMBsVqjafaa7TDuyfH0",
"P4KW9djKKlz0bmVn25rSaHEZJQxOfDP9CV6Fhwx5aahXKTtO2G55fhUQ6wow9MvEjvoBp3cV0Q+b9Q7/",
"WR7IM/57LuZYiGIfBBdVXKfA7QdwABaTZxyrF295lv424P7C5FeIKbyLNd3JNvjzqAK0AbLwiMlCVBTi",
"ZyA5D8HTzPNLcheTpma3NPBQGTEnzRiqRsw0d0h/h1JZYaiT5x4/SNmJj0D4UQ0q2Uy245d4cwA/iWvH",
"mrp4yUoeTV+V/U5oVeTNh2EVH0g5FHs1Ri1CW1czojNRy7QuOj0Y0qvlU6yQU9or6QwYZdM70ilRZdRS",
"r1uwQF5MQg1BEqysV3rU9fsdj4Rhy+0jJEXJLzLEozMS78uOZlzhe51Qp5mGNZYR5Ta5YDp1uBqqhcHN",
"noY0i9p8M+UhkQGkOm2xkH/A/BVl4Gpi/TQyrOSkVnvxKdsniOpphTzpGeZnXZxl1mIBZ8Yoo9sjjsiS",
"36HukkOUwg4l1OOH4bLdIa1QqQh7o1lS2rbqd0mLyo01R5+ImYXORC0BxPAziHXuggn/gmUL0s93k+34",
"KbaoQ6z8HXBC7OBtSdXjQ9B+H7F77gn4dPS6Acyv6/ud2qd2hK0SMK+A3benmGDXX3Jc0oLq8EBWVgHs",
"JA+KJRmuVPa4DKJfk4EHaVuttu+6di8kWmNsFxNAICV2V07hoFfoASREHQANTuKDZAvQMF/CXQhUeQq9",
"5pJfUiomW/TSNRMAvHt6CrCJ8iJCzUT/gdqCdDZg9pzQraCXtLQv6ZyNM2gTL2JJD5zC8Am9EcgyPXuB",
"s7KqNwaiVdJVbJqOHdyF+nj6i2YjXA8j0jWXwq/ZgWPj2RMpvMESDPAxvTi0PyRLGq5YAinghK7tGRJ/",
"8xIB8hZvkMoQw7l49ZboCSI4/oAnUG+CZ4tFVcF+jQ8BYagMqLgA7Gts9CnzssfCVj6fhZe11CvHJPtS",
"hm3GHHeQlMLDWR3MGVNCd4rAnJvi5B2meYNDC+QBPX9y15Ia4J41ZWYslw1z80fx0dhLyOa8jbOk4qNF",
"twgQjRnGtEYfpt9Wxe0QfjMTwBgm0hhgLArwj/+FV3o9FGVUlIt57BdrtZf7rtsUn9FNaX3c9yM780Xk",
"+y3XDlZI+pEANZZ+7qXZYGzdVayns5S0R/2wcmMtnGarAP51PD9rujO5sdOJSVskLbUcKixr0HCW4gxz",
"p5g3RWxQL+AwRxyL6nZYUIIfqifJQynVfMZCCLYlOyRvvI5GM0TMpFym4xStLtmiww4gNjia0ScVVwjN",
"yiZC6aPyBPNL/pv5t3/StOZ/+hMuNz8iS/NNtuYUS29o/YiKGAywcPfMHoP7pmIV/dHMzVKFNccwE4p2",
"0oyB6ne7TLsvQy0bU8qUxzLzQc9Lly+XwCfnEtU1uVmsp02yxeoeRIAa7uTPQetmCjpP8xrmMFjnJtTy",
"quD482iL0pFKd0QzzoGu47E/L5YIgLJT/47j6lIT+9GqX9qLADUmcACjnJFYq5TNx7rslpyo1SE9ZIhy",
"S3vJiQIWGS5/mO2HzHKlk2/7HdKu9uRpwCD4FdlyyRqiTXGlXjQt65LI7tiRjUVKKyToBY4XNZp0Qnea",
"VXqs8nf4y60zXaUZN3k5wfkb6ktFZvh+LfSQ6ODgAKKZBQqZsSrlaMZDUHG+/VP8FJRhuJ2yObTHVnxA",
"lXtodg/a/m6ylTwB6QLlPBKearIFGM9j01IshINXnnIlaVQkXcWIxzyLV6GDgX1yqrVwDq3s0EeJhNgL",
"Opc+H7FFOk5ksol4DLBljNb3/DB0llzSEmdAP9LHGGds9XtQPavZin9DeMHnUFCHwQDQEI7B+ZE8EhAX",
"sEmPOME5BuHzeA+rXOlF9ZlUiCIqx3Z5uAvqa2zX9e+1stPSWlSkTZw10mktrbN8ngqIMWhNnrHsFJEs",
"W9XFL737xppmteQI5KVccgQm3lTLfj5rAInjQFBW+EwNto3ifSuM7BXHW8GAkrTpIsFhYCVb2A30iEqk",
"ap3sVs6I5cJa1RUoYF+nWpYhSmupCVrWVCZR+ISFyqhqPIpfUqnUtDIl9/GupWzZ9BiN/Ar7a4xjGuaP",
"u+wx2EUPAQhNkV8lid5d3jzoIbg8h8zexUyoOiADK6TzSPafqqI1uY4n5fqoojOvGSmHNSdkxBXB1aNU",
"D1PFgqzO5XqaI6KNpN1khb7uktRoACYNJ69daS+EvPTXcFf+tGTljCpUzZhUst0vyZ1itb0OlBLJCHhV",
"LVykW98cKyy3++mTYxj/Yzw6ZmixkiUsAoqlbz/vAGNxfHGc8CLsqyHZXFYOq6qE1eR7ZV/sfEAg39WM",
"tFDi+ziNUypvH47lQClNSaqnRbcp3F3gyRjbhXEmr0W6d/rU0rM5x/UGQi4kyrEQQGlTogmH6HjCaihE",
"0MiYZQwpjapSTSn6wE2BEah7Or2er1dkPmraBQIIAXO864OUkr2fXRZCDx3Q2SmdC2X0h5fWFLSkeUqX",
"LOJKVK37FNy5I+YXzhJhenw7soDlBOydii+b3bqxfeZlzPezvq+7q1yn60RjWTJd+z56/sf5UY8E0BW3",
"Vfa+8vsCaqnHeTe8d8xfZYgrz1qZgWFl+Zfm6GbeqZvFPR06pC3QOcV56vUCf42I+Iu+GwGGa86W4FXg",
"w4UMu1Cb4PC1aiWxihyAzszXfOxCdwMI7QpzpYLbW99zENIEOcXKaa4XzHnt1IBeWhF/lcFaat1FFZ2H",
"+kR+nTEFOe5m2KZTpsoxTwWJIsdb0aoZOq9PJWcU4tQJT9Rh1g81hPYX4IMCdpL6lh2zMQA7IMWNQuAj",
"kPwyAp2CNgGYkmc0wl+1aMQo97giPbPn+UGMQi6zXh0ZmwZOKOcpU1D3dxCMHfHdN4Cl7kChicHTo8me",
"UkD8nnCnM0CRxc/oh7I/aDfZzlQUzmaryePjaV1A2HAoKjKkSol0PixRLbvagWm1p3Pyl/J2EVa0mc9N",
"i8oTGUoHKmy9vLy6D09Rtmi2nZH+0OR3tuAs5KoSZW8cA/Fjf2C5Yos1tGpynYBurtPt+UGEz+G/pY9b",
"y7bj5lRxVgCqZnTcc6LVTmDf8wBZqZcBNEp5hYdYT1v4eNpWizpCfmSvkTdNteOQ1rteybG+bLvuEnNq",
"aJBRDxAtPD7CxDUM8WtrLHht9oCVlIOF8pQdXfCeowv+cwQyhALBl2CBHcSjJvbDfApmGEIf5iNf8ciU",
"iutXa4kzVtufCYHVSXNopjvFFiG34+X7Ytp8Y6+ksba/Iu0eGCbxDiGdJW3Jm9+P2r6afEtp5hL0QLP+",
"VXedXovYgbueNq0qwjkeo5eWuesJn5mJsLx5sg7TbsgayB5karn244H1QwvgJIcaGMaldWgiow9jDFFN",
"RBM9HR1wKnc5GhzvLotIYyMVWH1q7aI1a117f8GatdiBe86TnZLP6YDTLPdVVJsJ/HZqq+AxPKqKkA5E",
"ossRbabzJhNsagv2tyT6KJULOXfTWGVOO0JJweuMrYtzc7JHZNd8F8ovCCPitaLVwO+vrFafGEA2GNB0",
"2XmuApXJhtHPI08ymSDSe5opNxk5WN6cvFV31q2ZGJ1hVS0jOoZxFzJ0TodpnoXmJur+VCt1lyVJWPkU",
"CfFZ3gFUO5UPRI+ajD7S1rorfovoiCM4MUcgZm7YEQkc27UW1rtLPiCl5gh/l6yrgJkpTHOx0KU/bOJk",
"7hSuIDTeYs4Y3fUlimgkkigmOvNY2YhNOlP+FtNqF6CT47nc2NAcsnNqVTXbiJIHmDJdREsh7BmAmNYa",
"0HJ14ETkFstVuL5KdKpF1wkxwjzGdsrD3mC/V0/dRU23OC0MxJeQPiCQbLB3e6rrUnUghY05wmbf1I5N",
"obw5XDO15f/8+b9bP577b4pDH5CRIPoggeceGlw8gd/3Oq0ocHqGC8rukizykRxdqtjJAtxtvI2F9E75",
"BU15a+5U2l2jO/j0EGDil6UzEIxwhr5NacsmTVLSSxXJGCwb2NZnya8BsOgk2db5NxiC+QkgjDBwAQEV",
"P4gPrlqLrCvRYprJo3DQsdSv6QiRGBFq4DlX/Rrj4BUXZCD9QeFSKfE7TZ3SIwx4HWIAwuY5TSeZflTJ",
"Vg7mUJO9NHYYS0oYEbMSG1vkY1aYaaG/skJC/X0sM/OpmRdEersfONH6AhVrzLQgdkCCa33cG/zrHS7d",
"/+ajW4AKhGWQ7Nt0N1ajqNd4QAd2GMp5fifQuQwQnnR3b769cMu6Nv+eNe/0+taNfui0Z6z4t4jYRZ+i",
"2gXi6z8EaK5tCRFLAbP27Z5zoe13yArxpm972GYl7QwNOJd+j3j0MTpfnNj0jAXBXij0TdM0gSMAFdPu",
"OTPdDqJmsch5I50rnbqEg83hs6mox1c1rjRem5mbeY0BzQCJ6ZizaxdnoWvJrN3vOCCvVgj8h+4zpAG8",
"12lcabzvhNE1eIL1iiIRCUJj6CR9ZPZ9p+tg17qSB69j9s6DOxB07fleiJxwaW5ONHLFtH+7h44vx/dm",
"/45FsPBGLMWToUuAlCZgj2xYGmvygPS/igdW/DzZSjaYoBpIbl7Ta8S805gRIK2zHo6N+J/SAbGA5wUL",
"ktBbcwo2Yropt8pINpmt/ClC10PqJW80c4eOru6i6ywFdrA+a3u2ux46dEoRax/ph5p9vUm/vsYebpyR",
"8tkOPeCirdQfhT+qkQ6abVJBu5gPP3kkgSVCCEnBrK5j975kCH1pAZD0Cqj25AiQudsRb7ItBWmL4YjJ",
"MDuikgvewBhC2ne2vUU7n/b8MR7k9/HRt3mTmO/xgZaXUv1c8waDx/FBHXzxf6XWN1W3Xemhc6qNXvXD",
"6ELHKdnrd/0wesvRbbNJOzHhksPSKPvv5FHJ8St6wz2GYETjSkO0I4HAQINnropdzeoLk+QSRgTArNEx",
"iIrNXousyAyZraAZKDDvaQSNgXlIv0MtA9AJLmhU6lOxTlrdoOWbnxAuIhZ41vakDzCvodCe3kfJF8lD",
"SEcBiowE5muyXcdWfcPAyI6THe49fgri4SVAQT5koL5I6aYlmrtVJDriicwGRHSuNV3F8AC2t8qT/JK5",
"Hxfk6UA5cLKJkGzJDu/YscUKUwf1MDXWu26mngAFoUXq8JXOKNnWMCkSRU8uzCeYlfAmTFyayWqZIJtm",
"3qTj099LADwv6hIjuUGzrTjypOV0u4NNk7VJGX9kBfWPualB90yE6zEPZ4ThmxTy6Jj3EMs0FOP3W7ID",
"NY3MiBWsION9wAKex3ssJZWXs2T0KLh8dqGmEMvbIb1jptHMcACDVMkzAfhe3vQ76xPaf8x1eaCqsSwh",
"7D8dC/5WuPdGApHq9DyZP++QV1GsxCywZyZIXQlAX29zYBnecXyQc2zVQeR/H+tib4orJNNlFtSERRlO",
"Xmo4oynjVZQQ1ZNx1Vr0lkM2Vq6hjxVGfmCvkAv2CvGimdtebgVDBSscZQMrwttDf53avDALfS8w8zP6",
"8xWl4I1pA8eimyDKMtSnUJfdhRTxIbpOVN5CkBzW8WAygkNuylFJYFys+dVadtZvFsI+U9ldnwZc3jdh",
"HAWWCYvZT5zOA2R/SLzQpB+klngKF6NYZ8mnVvwU6mhHubbLya85GFNuvk3puksTD1n7NEXDz99Xb8Fs",
"JXZTNv71AmjuYV2b8lW+uUIO4HvMTWoa1TPTSudeCYtPQkyXUWY8zwqTFR1wmZhUt99mom0A1LzH1PKB",
"Bf3XRK2MsCKLWqnk99ukap2DpHwlqtV5s1FWpapTLM6yCFqh4SRojU9OkLTsHUbFSuqiQgU0ZIdh68Ed",
"yqjJVh30zr4EQtzD1OU+gdPbj7SwUuh3BHUMU1BPILy3w3DUdU1e2H6KHi+W1OIFPAaLVIZnm77kTvB8",
"X7fr9R/j3Iaf3zEu47VvJH1UdB1XI7G1OJT+OR6ox/uz0/BftVM+22MZ5Fc+ORO/al1S8zj2efINeyXP",
"n3g1HMQmoWWiP0BaDPPTCpcttv2SusbUokEzi5pjvLCCKZ6a85S3y075iZeW6Lo0nZ7XeOehGvhLE8qD",
"9sKiQmwXAZyP4uFVHiB7hsoN64EV71qLP3n7lqV1aS/OWPEf0bbkI6JInfuxxZ6cgbZCLNVPIycX2rY3",
"WT2nbXsSez/Qe3pre1XaBMoQSpXaOzNncV0ykAeJRCs7fNnpGTHiKVV1C7pbJDw3a0XqVqWXL8N4P9kC",
"5E9wc1iZflOjukQLH3IktcpXOt+MzqSH6tq+TUaKZDrxAegB4sWmPbgOrMtSYv1AZ9sPmrnsx6cQkZcq",
"DJnbO5N1luzEh5iVJuVH5kULZAYqyVUTEjHmfMRzvk81aa86nv8tRhfOidNldYxVJmr6IY4bli/i/RBz",
"6CbD/V9mMyC1nC1lUe5C0GcI7ROO4gHLhECk4ZGMQA5d2iXlQtF2GJjaMN4Fyn0uN7zbVWg8c9uLf5/P",
"g5hCxDm1BTR3XeQOmMiLRD/GLhVWvLXqdGo38UtedMbse6LB0KLOBczSG/On8jxOhJRbqU1LYPofwlcP",
"mcXyQiFufZ5aOaSrHhHR3BQSV/Wwni9YS4JkKz4A1/zGuJdHWbyXmnUfimba450juTz3HceNIEf8E22i",
"SopYJ/aztCRPPxJg4dUxkBY6LpdJI6FKfL9yuDJgcxUzuGrO7ftKjUCzlrcqOK9eHyqIbbKvZpewdkCE",
"LcZj3re9yInWy9zCGkTdx00FLxgQJJ9h4wVFS0rVmwMpu53+YNdS69TpEEoZ+owVf5UC7GNLItbaItsF",
"FV4JbUmYg++h2dEsQcxPNKAvtSOYgGY0KczEiqgvmnNE9wpTZF/KHjApQib6I9Slew0AX3iggepKPrWY",
"0iz4Z8ROHmWiJ2Iyydbpjh1gbE3y0JWi1LJ0KRZs25YPBHNvCOmCO/EQLAzWPwdSDlgvsCeSbqUCvI11",
"pCZ6ml5hWgweFA3Hs9zcOrmZ2cs6/D09iM0ZmVfK4T8DC5vT/5GC74FYb7zynVKy+FO9dFifqsswyqQ+",
"Wfz0yP1VlGvuzFuIeLuSbptTcBimd7IF+THgulxMUXoXZcRuVfuZsW7a3grhDpw9+Oa5evtq3JwwtBAK",
"p+Usg8ZK7vcaWSGgVYTNIFf6gUNnpXDgU2SV9zuOP/sDlY3F/JYcz4b3Zwd+YO6qrCCvn55pWU1c48rP",
"7+QdGgLxfaDUJCqvxt6DHEEetEFA3cS+YgyT4zAeSG6xMRlcCj0ZxAv9PjXaJnf13HyVUSMNJmGxl4s1",
"a4ifg3g7pPvBVbBa4t1fCbEp4M5kDMeRwjPa/OyizQ+JUnOiCbJuYKogSPEDjmuW9stLtmasxY8XLbwe",
"mdhVvre4WjPEcl8W1trCUgCsJAZFiRsbeQEH7gKYak666QTLx4pJXdwz0SScoMVgsyLTYK/B75utfo2y",
"ACVrdVNdi/oGcEI13ef5wUuKNXj1pNGl+jU0dD1GTLBnmGwFh4lnv8qpshbHTNpXXaHWD36A6euIuDyI",
"93/wg6tCZz9hA4BdvguQxtBUlufVQ1eOI/C3ybkEM7c96DWJtzxrHyklb2KrFzmdcZGe10UF/xnCqIBL",
"xiv3jynRzBmydMMnJL7TF4wlvC/WPIEOLNFUG5YHLXysJMzWVIXEtlBE4/RYiaU1wCCgNSmyumRUsbNV",
"U1GTL+pZ7h+VlFMjKmj+ZpIO73gu4JAEJeb8H/iRl6KHphauUK+PB3MgTpCFd9VLKGtRLsDD+BmzaliX",
"QLyR91PrHl90gK4AI0Ho+f+a2ygMl+0ZTuwAolfYI/yZ2m4WcXVxPHDFoRoABce5pr2Z6SM/iiwKYK4Z",
"1w6jFuyENqiS9jqemNLXOY3UmMA9WNnN9sK0p48nUqQD3Z+bFr/Gmhq+rIYokEoTOgsSXejZYXjPDzrj",
"OyTkI6i/eKXuyrug4h1KYS9wB4prWXPjooNMvXOvMlRgvLI3OeagEYV4H1w8w2QDbKsnJvP5JiUF6kGM",
"GhNktluk2/MDO1gXL9Mx3ZeFBKvnisq0GJfHPw0rBQQ7RWGmazgJjsqDvWKmLVSxMYR74VyFiDjk3uyw",
"MqNM+gdm4v0ScnaBBw+g69bAmgKJisFxYL/4WdO6/s4FEPgHosv3U1CMoaRxunnb45VMAlemqKUb4mql",
"AJn0lhBQnVet/ATSZ5+L05E61+hN8jsJp3YP7gOBXIsWGKuL3sAI/DGbLz8VGskvzsXNzNZW0jE4g31W",
"X8FLlmfTXiMqQ5Twr7vU76Y6lSmt/hpDkZvctQMv0HqfpDhfsqlkOtUTyyl+QUq9th3Zrr9yCg1N8lfL",
"pO/1Zm2vE/hOZ7bj3/Nc3+7MfsKQjsy78RZ79Br+9FrvrsEPwIApmAnPEZSKfIws9bpxpfG/b9/u/NXt",
"2zPSf6YuBG32z+n//pcax+F41vma15lhq5/p2e27UHIZtFedNXJmj+W1+b+t3Tt5bf5vVXTfqfgg+TU0",
"G2K2M6/rHKj1psOm0YHN/duIhAGurQMepVQsg14vc2ol1nFtnjxqclulBj202zykEhUidEII07n8xImI",
"bbH6dGxfxNV2EWPPrFgqQ+UYvxYq5mkZPK84hBb14JhNk35GyZPbHqY/oVsAmvJKMOVWfIDzmrHi34vX",
"iBrtIf+a6/lwchFwiO6CRTeNKfivW3avN4NNA0tzp6jEQ/K+j9SdpOTDF90kLrFDYrLUJbuI3pfpQifg",
"hpffluxAaJbzfbJjsfle0OzmjjUFbnppG47jXc5ZPLKzr5yPYj4H0NIK1xPvuje5XcI36L3fmgSVWkwf",
"7cATvJAkajPFoBg2D56/hg9OWjdYQLoYS6CkW5zqj7o+3DvWFBdvrLoetcojhr9DlWR0Oqg6wHTNWkb9",
"8zsnnoj83oW0x0IJX9zye7d4b4QKYQroTaLEGATFL84B7C+2O7k8V9zfZaJBAliQkQXl0ny4aDnQIQto",
"Qo/47TR/ZpeBBNcULThB+w6QzLalNlbM3tGxG6tKNXXaRg8+FdafFkQ7Jsp+/Wh11vVXHE+OxWb9A/Ew",
"fmpNLbLuhpQjrlj3yNLiNJ/1ligJD8hyQMLVC6lyY8W7Vtv37zrEWuw5vX4riBZve1OL70ZR7wPPXb9q",
"LdCbkly1FuwuWXAi8tcLUeC0o6vWvB2t/rU81cXpJr8fBVIHkn1oLbJXtyL/LgHvIlVpmJLI3BoiUChX",
"ukuergGDE8ZCMMWRxAEjc15O2WmCFzBKFTk+w2HaRJOp297i63MXLbqmGVae22oHpEO8yLFdqEb7muXI",
"b6NRqzRFOgF23EYig9ySK/lZTvuPcfjI91td21tvUeOj24tCrUb2PvDAZHyuMParcrf2o9VblCH0ZWx/",
"hHM7TD7je8g2rH6t70vGCfqIdcqGT2R9rR9lkc7YefWxSlyfPPE+fl/RccJ8m1kXJsQnmrmTazEAyhqx",
"4b5MtuVTIqIbmNN4wGoLXyh+2KpEumC7bhmhrrluNVp9eVp38LCu+vCUVCLbFS4PLJdEXC2GCzxIPi0n",
"EhOZZtl/UyPOn8KqvpARbHJcMgXuy6fTQs6C2YJX2sCaYjIcsKvBMbiB5SniLdzDyXyOI/Eyfs+mJGfy",
"9/CqCIGfSKl69PdDXnC0l/yfZEdJypdjaWj/YgIxSOrD5DH9p2ym7+pdl0AkJmb02hhSSKrMQEqVA5vW",
"L47ZdAsric9NAP+enZsdLgMHGYLXL4n/AJvO0S7zCgvC8xsPTttfk6Pk5VmZya+YtY6NqtMkTRgJczTl",
"agsICkj5d4jz3ExL9OGcH8SDK2oKPHhkpKTiQ2txbRHjt2mjjaN4MH31ticjyyvlKix2vRg6vyBUC+G5",
"tUAzPnNvxfHuyw6H/3nhWrtN3As3SccJSDsy+Hyu+9iDqOac0bWxEjtryg81DvMLoreyXpuba/LuMW+8",
"3qR/Xrw0N3fnVCaW07VXyOw9stQ7e/rpv4D8heYElIvqP3CZOp8Mq39Elub1Jo506MgafXGBjfK1KvOZ",
"LxT8bGmSPYOf3Ya42Y41Bc+zpsROZ5qDBspFF2kEbw/uiOfxXrKtwcjxw+htnONkhDYM/uarKJNI31yQ",
"oQp9vznaaz0Gd6ZtJmwadlMxWNPWFBVk1uW5OZmdeq69jm0jZX7CPowmd+eNiRby3iBGEooIcR0k/Deh",
"PY+KckhSUnVJIfQv699jLfadVi8gy+GihSicyjlJNlX4Tp7FchiPrmZKsZX6lxM2Oj6SqS80lSbdIBNN",
"E7pBXpHVamARU3ZQ3ZxjzAnaAV35oKmF8h0B0Cu1DX6J/UkURQqYSzmBs0u+H4VRYPcuSK6PAhn/b3I+",
"HWbDLnb7YdRqr9reCsk5UOSknz3wErEIGNVlsIfPIAWMPcZMutTifBYfz9z20u5mrPdCzgTMpOMxqNrd",
"ZJvrSzxwWaLpakEGSPQmp9J1iUiT4Xrdq76rfhvM52fCQ5Ek+wJEDezuZgXS1wdxz3LQOCrSZnyQgzWX",
"nZCZ1LQ08VLUJzCdpuQosR7BhdCNb/Fnvtd9W24Sux2RDkQKqpYS1LG9v1GznKr0Wmr7rkvaMKfsfq06",
"YeRjaaYpzfq6S+zgXfZcJf/UV5Jn7rguaIORBMSlpHU9gWiarE9n07zSRme5ZDU1pibTyQwKnNLie8y+",
"bBHVi2AkkteEyq+m5hlzBs/I4aXi6P3/kkWn3cLf1SSFfG/Jt4OO462A1tXXbNSCvUY+SB+bjN6RvuD6",
"qu9wPOUyZeN1Qw2hDF95XFebH1Gbz2Ab0o5LuexZ8N/x8CLc7YomwLAN+ZadsAY4ikS8Z6/l73glfV7f",
"xaHdDwLiRS3+6CIClO1QfUQElI+aVnyAAco0fP4I1CNFnU4HmRJxTNaEXw2MsuSzNIQ1nerNparyVVXp",
"hpLWXDSDjl+PQn0d1qbk3k+gNE15yXdTg1asI+EfpmbleSnM36g2pr4kQKPrco/OhTBiHWRNV8w8e3Ih",
"mjA2m/oiE4K00irsaq4V3gmcTqgLHSafJ18wV4hkSoDrdDPZqSmjRakFY11XMu3MxJyoUOMJLyzhdh8u",
"tBFYu0obJoP7ram/Yeb7mn2aAEq0/I4xuqqcN5MofjMIXub3ZTKXWun+u3YYXUCAynuOF1bxssJhdZ0w",
"Ks5omxdPTZj+LjQx1eeXLfTbbYA7PiVxDeaMPvUAi4f5lCbI83T4V9JESKztvEhtYLyK9czKVpTpmfiT",
"Tt2sUnSPuRNOwD737apSVON0qoEBGVAx7xiuHPTun9Phw5e9isvmFZ6+gFrGju+Fs5jPXHoIwdsFhvRN",
"9tOq+Ws1Bh3ZYLzYbxAfgILzpFGVhcfCVdOa2yTKE2ESKUA4/BgXw7lZ28y5ITLHlPJSKTO/mosjJGHI",
"K4SN6seN9QX+2CRB/PEd1TpFpmZzgSOulrIJgf7Ax0/t9SldXuF0sXXGCV564m+SNf8uEaRvnM99cOaU",
"2LpaXPBBhe7NX/qE1TjmST8soLzqytMmpS1ywkltPqEenCPH5DxZV4xZA5bcxpf5kD9lAAovLaySH2bc",
"X9R6xRx7LMIBZHjFvY0FjY8QMhww3g2JZBmP5IQOrPQWLWwCOCxGYK09hJqjCZlqX+Xr81P8ANhKTfkJ",
"K8R/DtPboNPTtSbnpbEpQP+g0A3Jum8bWYyeuL+2XamTLG4ktvjmwXvMGpRblSCEf4psh70y4lG8Fw94",
"4iO0IHjGgBeg9B4TGSnbHkDxw0MOc08l2glMQWBKg/s92YYeaB65H7XaECDQQ/vzTuoV4d/MEkmCg7s0",
"N1cZDw4e02YRNmzIVmd5hOwvVkRG/8WLBVkZ2R3tK79PMRbcC0w9C6v1vampub8Ojg599YOSfvSscb/U",
"vEOfJfyPUhdj7uiDaFyypemWDi03XkBz/KP4uJmBkpOS3lkOr2g5Y8V7UFH+jN4lgG4Dq3gJ6QYHdGT8",
"g5437I68kWzf9qa+/dPK6lIn8r59af35V19a3/6JlSRDQfq3L6eN+HUoj2SMap6qJK0R+nG1/SAg7Yh0",
"WnACCk4j63hxHofy1bIz9NRg9+V+XXjqXybbID73IPCCGYEi+EQVTpm7UXbvxcfWa/qrZUiV0tfUTN7j",
"eLdpXU718+N4t/SUBGskuOB4y775iNCJ76v45+I+GLAGM9hf/JeIgoM3wxULC1Aoi/KCQblHEKYWyoX3",
"TzJdzAFkBx4/ZC1L5AY/cv48ROkNGBS8RpUlzoFO/Wu4raB5mqZzVrIzbVB6FoBe71FyTZRBxVv0Rsou",
"hAxEc53jDBpH/enjpa8UWoy8KRn+Ww8j0s3wHwBZ11HIoaBtCwM1i6JcUCw2ElUd1sVLVvJo+qqlVmgc",
"IgqEWqyBpsPix1DrerpCjSsW072ZXsWBoPONoAQECLsVmUq3R8X4B71+yFuZA4LJQCvHgUrg3qi/BqR/",
"JsPwuw5G3qwCPVx4qoH2P+vbLj1hrwbdvH7JkAKna06dAQ2oPGrFPJZLPA1eH8D5CfPUhZPsNQNveK8T",
"nrf3uCIOA6+/5z2303A15pQNrorWbVLTGMkr8Hl9pbHSpLDZGM+sSbYspwMFqfTylqtKlTJQqS9CaTVS",
"xqdtCttwUTfZbSraohoJO0EQCql/myHz/zd4TZ6kVUbQBEhpAAioB1E/xGt4KuqH1sWZuZm5pgrE++7b",
"196yZq35a7euvwuJUOibGqHC+8brVvyv8RfTiIeFN3aq/+1ChuQQlNA/b/zmtrfIpj7zcZ/0SWu577qL",
"1qwlPu6HJGh93PcjG79rUlNsC70wOGH+pGjQtigKPcWLBxY6R3bRcVOAtn2eza9eEeq2NAMGwG1Mb5Lq",
"xxjcdj3u2xzgNu81xZCQUddiGqqKhiz29NiaEgjzTataFwV9d6rUv66SQGZ0JhMlqDgAOBGgIckWz03c",
"iEfJF/Gh0jSG/pYepynAiAZ3wTH4PDcYePTA+qBHvGvz703nC7U+cqLVTmDfk7pIVUhkf4heBRTVTZls",
"cLHsgg58DM7CGrPdxVu5T56euC8EeoCMxYZWB7oyWWhgpICspztYq5Cc7ToeKQlm1dzr8j97R0jd7tbS",
"YuJfEWlMM36yc4bj3wtI2hLbhIad8R1mRRLiS85YC+9eu3Dp8htggYoOcrLlyVvQMRWaAVxOdfx2yCc0",
"0+38xeuakz/P5nkeXXz4u15pH590Et+BTj68BbDaGHuQKiAQftvjjudnwBP76KI6NWuCqlOkEiOhfgaP",
"TXw/8DW6bfgdh9LCsB1QgOWRsOpjvG3q2Ih/YXpbtvtaUavYU29ASIhntllv2AE/jQv0wcqXMSXIo3pv",
"WmyCOJJuWrOsFAblIaUk9vTjReVVKdP3OG20vPkhfI3EeYu0nVqyQdQ2rm2/jz8S8IpzOo+R0qEVf1Op",
"Q+t/8N7r9ai42XgtV3IYlIAMZKzfNUT7Zlv3Gat6ZVHdfbxSTsHo9+w1Muv3osKcnp+Q6CN7jXzQiyad",
"0yO/pkKOQF3JIyirRug6zkA2cywMHlUQQX7n4o881h6NBc3Lio6A2HIGlV6sLER2AASf0BVPh4Z36LGq",
"Ltb6IgljpSgvSGrEJcpkHp0NPifd4t/DYGiFfPsnqkcmT9KWDVvfvqy+a6VpWG97HbF1pXfBP7+KbCjR",
"qiK/6mYtvZl1ycETZufrdKD7566nFjN4BoutNnbOFHqlyhbVdWY18oslO1hUJUoeCueEcOmk/bFBeYJW",
"Kzw6PObRmPXoNkyiQ/JPyf1JC8WfMhZ6dSzzdb18AuVoyRakKKh8CLhCvIsndBKRMrSpTsLwMLcVLJAU",
"olnPFqvEdqPVXxQpEu+yRyaJCQCvQL9LATTUr1AoWhjTKo6M/ROEvUS2wok8gJq+JAfGA2J31gupcROf",
"mGxNfWe9gBYMjmefRcA307QM5sPnvS14YOny3GvnNrmvoJhSxMo1qY/aiQ+RgYf56Rft8T+AWXmMjfv4",
"Th/HT7Gim0c+U5nLc8u2ramK5JvWMoo6p08aS8QOSHCtH63SKVKBiJk9KEj7gdu40pgFQcnG+kSEwXHM",
"B03xCcBtSn93ifIt9FeSPnCdpYBSQ/qIB6ekj1jykfSJCPzKP0yLCKRPQVjIU+j1Gg/uPPj/AQAA//86",
"zW9kz1wBAA==",
"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",
"AQA=",
}
// GetSwagger returns the content of the embedded swagger specification file
+210 -50
View 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 apk == nil || sum == nil {
if len(files) == 0 {
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))
+1
View File
@@ -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)
}
+4 -2
View File
@@ -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