feat(desktop): wrap the web client in a Tauri app
ci / api / api (push) Successful in 43s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m19s
ci / desktop / rust (desktop proxy) (push) Failing after 3m34s
ci / ts / ts (packages + web) (push) Failing after 2m49s
release / release (версия и заметки) (push) Successful in 5s
ci / db / db (push) Successful in 40s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m12s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m28s
release / android (APK в релиз) (push) Failing after 2m0s
ci / api / api (push) Successful in 43s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m19s
ci / desktop / rust (desktop proxy) (push) Failing after 3m34s
ci / ts / ts (packages + web) (push) Failing after 2m49s
release / release (версия и заметки) (push) Successful in 5s
ci / db / db (push) Successful in 40s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m12s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m28s
release / android (APK в релиз) (push) Failing after 2m0s
EOF Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
fa79394869
commit
efe78d7e07
33 files changed
+1056
-20
No files matched your search
@@ -18,7 +18,9 @@ const MyUploadsPage = lazy(() => import('../pages/uploads/MyUploadsPage').then((
|
||||
const SuggestPage = lazy(() => import('../pages/uploads/SuggestPage').then((m) => ({ default: m.SuggestPage })));
|
||||
const SourcesPage = lazy(() => import('../pages/admin/SourcesPage').then((m) => ({ default: m.SourcesPage })));
|
||||
const UsersPage = lazy(() => import('../pages/admin/UsersPage').then((m) => ({ default: m.UsersPage })));
|
||||
import type { DesktopHandle } from '../desktop';
|
||||
import { AlbumPage } from '../pages/AlbumPage';
|
||||
import { ConnectPage } from '../pages/ConnectPage';
|
||||
import { TrackPage } from '../pages/TrackPage';
|
||||
import { ArtistPage } from '../pages/ArtistPage';
|
||||
import { CollectionPage } from '../pages/CollectionPage';
|
||||
@@ -34,6 +36,7 @@ import { RouteError } from '../widgets/RouteError';
|
||||
|
||||
export interface RouterContext {
|
||||
session: AuthSession;
|
||||
desktop: DesktopHandle | null;
|
||||
}
|
||||
|
||||
/** Куда пользователю можно попасть сейчас: обязательные шаги первого входа важнее всего. */
|
||||
@@ -52,9 +55,12 @@ export function safeRedirect(target: unknown): string {
|
||||
return typeof target === 'string' && target.startsWith('/') && !target.startsWith('//') ? target : '/';
|
||||
}
|
||||
|
||||
function requireUser(session: AuthSession, location: ParsedLocation): AuthUser {
|
||||
function requireUser(session: AuthSession, location: ParsedLocation, desktop: DesktopHandle | null): AuthUser {
|
||||
const user = signedInUser(session);
|
||||
if (!user) throw redirect({ to: '/login', search: { redirect: location.href } });
|
||||
if (!user) {
|
||||
if (desktop && !desktop.server) throw redirect({ to: '/connect' });
|
||||
throw redirect({ to: '/login', search: { redirect: location.href } });
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
@@ -66,12 +72,23 @@ const rootRoute = createRootRouteWithContext<RouterContext>()({
|
||||
// Публичная: ссылку на приложение отправляют тем, у кого ещё нет аккаунта на этом устройстве.
|
||||
const androidAppRoute = createRoute({ getParentRoute: () => rootRoute, path: '/android', component: AppDownloadPage });
|
||||
|
||||
const connectRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/connect',
|
||||
beforeLoad: ({ context }) => {
|
||||
if (!context.desktop) throw redirect({ to: '/login' });
|
||||
if (context.desktop.server) throw redirect({ to: '/login' });
|
||||
},
|
||||
component: ConnectPage,
|
||||
});
|
||||
|
||||
const loginRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/login',
|
||||
validateSearch: (search: Record<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 +102,7 @@ const setupAdminRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/setup/admin',
|
||||
beforeLoad: ({ context, location }) => {
|
||||
const user = requireUser(context.session, location);
|
||||
const user = requireUser(context.session, location, context.desktop);
|
||||
if (homeFor(user) !== '/setup/admin') throw redirect({ to: homeFor(user) });
|
||||
},
|
||||
component: () => <SetupPage variant="admin" />,
|
||||
@@ -95,7 +112,7 @@ const setupPasswordRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/setup/password',
|
||||
beforeLoad: ({ context, location }) => {
|
||||
const user = requireUser(context.session, location);
|
||||
const user = requireUser(context.session, location, context.desktop);
|
||||
if (homeFor(user) !== '/setup/password') throw redirect({ to: homeFor(user) });
|
||||
},
|
||||
component: () => <SetupPage variant="password" />,
|
||||
@@ -106,7 +123,7 @@ const appRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
id: 'app',
|
||||
beforeLoad: ({ context, location }) => {
|
||||
const user = requireUser(context.session, location);
|
||||
const user = requireUser(context.session, location, context.desktop);
|
||||
const home = homeFor(user);
|
||||
if (home !== '/') throw redirect({ to: home });
|
||||
return { user };
|
||||
@@ -246,6 +263,7 @@ const adminSettingsRoute = createRoute({
|
||||
|
||||
const routeTree = rootRoute.addChildren([
|
||||
androidAppRoute,
|
||||
connectRoute,
|
||||
loginRoute,
|
||||
setupAdminRoute,
|
||||
setupPasswordRoute,
|
||||
@@ -273,8 +291,12 @@ const routeTree = rootRoute.addChildren([
|
||||
]),
|
||||
]);
|
||||
|
||||
export function createAppRouter(session: AuthSession, opts: { history?: Parameters<typeof createRouter>[0]['history'] } = {}) {
|
||||
const router = createRouter({ routeTree, context: { session }, defaultPreload: false, ...opts });
|
||||
export function createAppRouter(
|
||||
session: AuthSession,
|
||||
opts: { history?: Parameters<typeof createRouter>[0]['history']; desktop?: DesktopHandle | null } = {},
|
||||
) {
|
||||
const { desktop = null, ...rest } = opts;
|
||||
const router = createRouter({ routeTree, context: { session, desktop }, defaultPreload: false, ...rest });
|
||||
// Выход, блокировка, отзыв сессии — guards пересчитываются, и пользователь уходит на /login.
|
||||
session.subscribe(() => void router.invalidate());
|
||||
return router;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createDesktop, isDesktop } from './desktop';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('desktop bridge', () => {
|
||||
it('is inactive in the browser', () => {
|
||||
expect(isDesktop()).toBe(false);
|
||||
});
|
||||
|
||||
it('bootstraps through the Tauri invoke surface', async () => {
|
||||
const invoke = vi.fn(async (cmd: string) => {
|
||||
if (cmd === 'bootstrap') return { proxyBase: 'http://127.0.0.1:9/abc', server: 'https://music.example' };
|
||||
if (cmd === 'set_server') return 'https://music.example/pipu';
|
||||
return null;
|
||||
});
|
||||
(window as Window & { __TAURI__?: unknown }).__TAURI__ = { core: { invoke }, event: { listen: vi.fn() } };
|
||||
const desktop = await createDesktop();
|
||||
expect(desktop?.proxyBase).toBe('http://127.0.0.1:9/abc');
|
||||
expect(desktop?.server).toBe('https://music.example');
|
||||
await desktop?.setServer('https://music.example/pipu');
|
||||
expect(invoke).toHaveBeenCalledWith('set_server', { url: 'https://music.example/pipu' });
|
||||
delete (window as Window & { __TAURI__?: unknown }).__TAURI__;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { AuthUser, RefreshStore } from '@pipu/core';
|
||||
import { createContext, createElement, useContext, type ReactNode } from 'react';
|
||||
|
||||
type Tauri = {
|
||||
core: { invoke: (cmd: string, args?: Record<string, unknown>) => Promise<unknown> };
|
||||
event: { listen: (event: string, handler: (e: { payload: string }) => void) => Promise<() => void> };
|
||||
};
|
||||
|
||||
function api(): Tauri | undefined {
|
||||
return (window as Window & { __TAURI__?: Tauri }).__TAURI__;
|
||||
}
|
||||
|
||||
export function isDesktop(): boolean {
|
||||
return api() !== undefined;
|
||||
}
|
||||
|
||||
function invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
|
||||
const tauri = api();
|
||||
if (!tauri) throw new Error('Tauri API unavailable');
|
||||
return tauri.core.invoke(cmd, args) as Promise<T>;
|
||||
}
|
||||
|
||||
export type DesktopHandle = {
|
||||
proxyBase: string;
|
||||
server: string | null;
|
||||
setServer: (url: string) => Promise<string>;
|
||||
clearServer: () => Promise<void>;
|
||||
refreshStore: RefreshStore;
|
||||
listenMedia: (handler: (action: string) => void) => Promise<() => void>;
|
||||
setMedia: (info: { title: string; artist: string; album: string; coverUrl?: string | null; playing: boolean }) => void;
|
||||
};
|
||||
|
||||
export const DesktopContext = createContext<DesktopHandle | null>(null);
|
||||
|
||||
export function DesktopProvider({ value, children }: { value: DesktopHandle | null; children: ReactNode }) {
|
||||
return createElement(DesktopContext.Provider, { value }, children);
|
||||
}
|
||||
|
||||
export function useDesktop(): DesktopHandle | null {
|
||||
return useContext(DesktopContext);
|
||||
}
|
||||
|
||||
let active: DesktopHandle | null = null;
|
||||
|
||||
export function desktopHandle(): DesktopHandle | null {
|
||||
return active;
|
||||
}
|
||||
|
||||
export async function createDesktop(): Promise<DesktopHandle | null> {
|
||||
if (!isDesktop()) return null;
|
||||
const boot = await invoke<{ proxyBase: string; server: string | null }>('bootstrap');
|
||||
const handle: DesktopHandle = {
|
||||
proxyBase: boot.proxyBase,
|
||||
server: boot.server,
|
||||
setServer: async (url) => {
|
||||
const server = await invoke<string>('set_server', { url });
|
||||
handle.server = server;
|
||||
return server;
|
||||
},
|
||||
clearServer: async () => {
|
||||
await invoke('clear_server');
|
||||
handle.server = null;
|
||||
},
|
||||
refreshStore: {
|
||||
get: () => invoke<string | null>('token_get'),
|
||||
set: (token) => invoke('token_set', { token }),
|
||||
clear: async () => {
|
||||
await invoke('token_clear');
|
||||
await invoke('user_clear');
|
||||
},
|
||||
getUser: async () => {
|
||||
const json = await invoke<string | null>('user_get');
|
||||
return json ? (JSON.parse(json) as AuthUser) : null;
|
||||
},
|
||||
setUser: (user) => invoke('user_set', { json: JSON.stringify(user) }),
|
||||
},
|
||||
listenMedia: async (handler) => {
|
||||
const tauri = api();
|
||||
if (!tauri) return () => undefined;
|
||||
return tauri.event.listen('media', (e) => handler(e.payload));
|
||||
},
|
||||
setMedia: (info) => {
|
||||
void invoke('media_set', {
|
||||
title: info.title,
|
||||
artist: info.artist,
|
||||
album: info.album,
|
||||
coverUrl: info.coverUrl ?? null,
|
||||
playing: info.playing,
|
||||
});
|
||||
},
|
||||
};
|
||||
active = handle;
|
||||
return handle;
|
||||
}
|
||||
+12
-3
@@ -9,6 +9,7 @@ import { I18nextProvider } from 'react-i18next';
|
||||
import { App } from './App';
|
||||
import { createAppRouter } from './app/router';
|
||||
import { applyTheme } from './app/session';
|
||||
import { createDesktop, DesktopProvider } from './desktop';
|
||||
|
||||
const LOCALE_KEY = 'pipu.locale';
|
||||
|
||||
@@ -63,15 +64,23 @@ i18n.on('languageChanged', (l) => {
|
||||
}
|
||||
});
|
||||
|
||||
const session = new AuthSession({ clientType: 'web', deviceName: deviceName() });
|
||||
const desktop = await createDesktop();
|
||||
const session = new AuthSession({
|
||||
clientType: desktop ? 'desktop' : 'web',
|
||||
deviceName: desktop ? 'Pipu / Desktop' : deviceName(),
|
||||
baseUrl: desktop?.proxyBase,
|
||||
refreshStore: desktop?.refreshStore,
|
||||
});
|
||||
// До первого рендера узнаём, есть ли сессия (refresh-cookie), — guards роутера решают сразу.
|
||||
await session.restore();
|
||||
const router = createAppRouter(session);
|
||||
const router = createAppRouter(session, { desktop });
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<App session={session} router={router} />
|
||||
<DesktopProvider value={desktop}>
|
||||
<App session={session} router={router} />
|
||||
</DesktopProvider>
|
||||
</I18nextProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useRouter } from '@tanstack/react-router';
|
||||
|
||||
import { useDesktop } from '../desktop';
|
||||
import { Button, Field, FormError } from '../shared/ui/ui';
|
||||
import { AuthLayout } from './AuthLayout';
|
||||
import s from './auth.module.css';
|
||||
|
||||
export function ConnectPage() {
|
||||
const { t } = useTranslation();
|
||||
const desktop = useDesktop();
|
||||
const router = useRouter();
|
||||
const [address, setAddress] = useState(desktop?.server ?? '');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!desktop) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await desktop.setServer(address);
|
||||
await router.invalidate();
|
||||
await router.navigate({ to: '/login' });
|
||||
} catch (err) {
|
||||
const text = err instanceof Error ? err.message : '';
|
||||
setError(/Invalid|query|fragment|credentials/i.test(text) ? t('server.invalid_address') : t('server.offline'));
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<h1>{t('server.title')}</h1>
|
||||
<p className={s.lead}>{t('app.tagline')}</p>
|
||||
<form onSubmit={(e) => void submit(e)} noValidate>
|
||||
<Field
|
||||
label={t('server.address')}
|
||||
value={address}
|
||||
onChange={setAddress}
|
||||
autoComplete="url"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
required
|
||||
/>
|
||||
<FormError>{error}</FormError>
|
||||
<Button type="submit" variant="primary" className={s.submit} busy={busy} disabled={!address.trim()}>
|
||||
{t('mobile.connect')}
|
||||
</Button>
|
||||
</form>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { useState, type FormEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useSession } from '../app/session';
|
||||
import { useDesktop } from '../desktop';
|
||||
import { useErrorText } from '../shared/lib/errors';
|
||||
import { Button, Field, FormError } from '../shared/ui/ui';
|
||||
import { AuthLayout } from './AuthLayout';
|
||||
@@ -11,6 +12,7 @@ import s from './auth.module.css';
|
||||
export function LoginPage() {
|
||||
const { t } = useTranslation(['auth', 'common']);
|
||||
const session = useSession();
|
||||
const desktop = useDesktop();
|
||||
const errorText = useErrorText();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
@@ -41,7 +43,13 @@ export function LoginPage() {
|
||||
footer={
|
||||
info.data ? (
|
||||
<>
|
||||
pipu {info.data.version} · <a href="/android">{t('app.link')}</a>
|
||||
pipu {info.data.version}
|
||||
{desktop ? null : (
|
||||
<>
|
||||
{' · '}
|
||||
<a href="/android">{t('app.link')}</a>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { components } from '@pipu/api-client';
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useSession, useUser } from '../app/session';
|
||||
import { useDesktop } from '../desktop';
|
||||
import { useErrorText, useFieldError } from '../shared/lib/errors';
|
||||
import { formatDateTime } from '../shared/lib/format';
|
||||
import { Button, Field, FormError, Segmented } from '../shared/ui/ui';
|
||||
@@ -297,6 +299,8 @@ function PasswordSection() {
|
||||
function SessionsSection() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const session = useSession();
|
||||
const desktop = useDesktop();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const errorText = useErrorText();
|
||||
const sessions = useQuery({
|
||||
@@ -333,6 +337,19 @@ function SessionsSection() {
|
||||
</div>
|
||||
<FormError>{sessions.error || revoke.error ? errorText(sessions.error ?? revoke.error) : null}</FormError>
|
||||
<div>
|
||||
{desktop && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
await desktop.clearServer();
|
||||
await session.logout();
|
||||
await navigate({ to: '/connect' });
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{t('mobile.change_server')}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="danger" onClick={() => void session.logout(true)}>
|
||||
{t('settings.logout_all')}
|
||||
</Button>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user