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
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>
This commit is contained in:
1 parent
efe78d7e07
commit
597067689a
30 files changed
+1180
-395
No files matched your search
@@ -126,7 +126,7 @@ pub fn run() {
|
||||
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");
|
||||
let icon: Image = include_image!("icons/32x32.png");
|
||||
TrayIconBuilder::new()
|
||||
.icon(icon)
|
||||
.menu(&menu)
|
||||
|
||||
@@ -95,7 +95,9 @@ async fn forward(State(proxy): State<Proxy>, request: Request) -> Response {
|
||||
|| origin.as_deref().is_some_and(|s| !allowed_origin(s))
|
||||
{ return StatusCode::FORBIDDEN.into_response(); }
|
||||
let prefix = format!("/{}/api/", proxy.secret);
|
||||
let Some(tail) = request.uri().path().strip_prefix(&prefix) else { return StatusCode::NOT_FOUND.into_response(); };
|
||||
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")
|
||||
@@ -106,7 +108,7 @@ async fn forward(State(proxy): State<Proxy>, request: Request) -> Response {
|
||||
let mut response = if request.method() == Method::OPTIONS {
|
||||
StatusCode::NO_CONTENT.into_response()
|
||||
} else {
|
||||
match transfer(&proxy, request, tail.to_owned()).await {
|
||||
match transfer(&proxy, request, tail).await {
|
||||
Ok(response) => response,
|
||||
Err(_) => (StatusCode::BAD_GATEWAY, "Server unavailable").into_response(),
|
||||
}
|
||||
|
||||
@@ -69,8 +69,16 @@ 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,
|
||||
@@ -262,6 +270,7 @@ const adminSettingsRoute = createRoute({
|
||||
});
|
||||
|
||||
const routeTree = rootRoute.addChildren([
|
||||
downloadAppRoute,
|
||||
androidAppRoute,
|
||||
connectRoute,
|
||||
loginRoute,
|
||||
|
||||
@@ -1,25 +1,27 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useSession } from '../app/session';
|
||||
import { formatBytes } from '../shared/lib/format';
|
||||
import { detectAppClient, otherAppClients, type AppClient } from '../shared/lib/platform';
|
||||
import { checkIcon, copyIcon, downloadIcon } from '../shared/ui/icons';
|
||||
import { Icon } from '../shared/ui/ui';
|
||||
import { AuthLayout } from './AuthLayout';
|
||||
import s from './auth.module.css';
|
||||
|
||||
/**
|
||||
* Публичная страница Android-приложения (/android): ссылку можно просто отправить человеку.
|
||||
* APK — последний релиз, который сервер сам берёт из Gitea (docs/mobile.md#самообновление).
|
||||
* Публичная страница приложений (/download, /android): ссылку можно отправить человеку.
|
||||
* Сборки — последний релиз Gitea через сервер; первым предлагается клиент текущей ОС.
|
||||
*/
|
||||
export function AppDownloadPage() {
|
||||
const { t, i18n } = useTranslation(['auth', 'common']);
|
||||
const session = useSession();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const detected = useMemo(() => detectAppClient(), []);
|
||||
const latest = useQuery({
|
||||
queryKey: ['android-latest'],
|
||||
queryFn: async () => (await session.client.GET('/api/v1/app/android/latest')).data ?? null,
|
||||
queryKey: ['app-clients'],
|
||||
queryFn: async () => (await session.client.GET('/api/v1/app/clients')).data ?? null,
|
||||
staleTime: 10 * 60_000,
|
||||
});
|
||||
const server = location.origin;
|
||||
@@ -28,39 +30,59 @@ export function AppDownloadPage() {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
});
|
||||
const r = latest.data;
|
||||
const data = latest.data;
|
||||
const files = Object.fromEntries((data?.clients ?? []).map((c) => [c.client, c]));
|
||||
const primary = detected ? files[detected] : undefined;
|
||||
const others = otherAppClients(detected).filter((id) => files[id]);
|
||||
|
||||
return (
|
||||
<AuthLayout footer={<a href="/login">{t('app.to_login')}</a>}>
|
||||
<span className={s.badge}>Android</span>
|
||||
<span className={s.badge}>{detected ? t(`app.platform.${detected}`) : t('app.badge')}</span>
|
||||
<h1>{t('app.title')}</h1>
|
||||
<p className={s.lead}>{t('app.subtitle')}</p>
|
||||
{latest.isPending ? null : r ? (
|
||||
{latest.isPending ? null : data ? (
|
||||
<>
|
||||
<a className={s.download} href={r.download_url} download>
|
||||
<Icon src={downloadIcon} size={22} />
|
||||
<span>
|
||||
{t('app.download')}
|
||||
<span className={s.downloadMeta}>
|
||||
{t('app.version', { version: r.version })} · {formatBytes(r.size, i18n.language)}
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
<ol className={s.steps}>
|
||||
<li>{t('app.step_install')}</li>
|
||||
<li>
|
||||
{t('app.step_server')}
|
||||
<button type="button" className={s.server} onClick={copy} aria-label={t('app.copy_server')}>
|
||||
<code>{server}</code>
|
||||
<Icon src={copied ? checkIcon : copyIcon} size={16} />
|
||||
</button>
|
||||
</li>
|
||||
<li>{t('app.step_updates')}</li>
|
||||
</ol>
|
||||
<details className={s.details}>
|
||||
<summary>{t('app.checksum')}</summary>
|
||||
<code>{r.sha256}</code>
|
||||
</details>
|
||||
{primary ? (
|
||||
<DownloadButton client={detected!} href={primary.download_url} meta={`${t('app.version', { version: data.version })} · ${formatBytes(primary.size, i18n.language)}`} />
|
||||
) : detected ? (
|
||||
<div className={s.hint}>{t('app.unavailable_platform', { platform: t(`app.platform.${detected}`) })}</div>
|
||||
) : null}
|
||||
{(primary || others.length > 0) && (
|
||||
<ol className={s.steps}>
|
||||
{detected && <li>{t(`app.step_install.${detected}`)}</li>}
|
||||
<li>
|
||||
{t('app.step_server')}
|
||||
<button type="button" className={s.server} onClick={copy} aria-label={t('app.copy_server')}>
|
||||
<code>{server}</code>
|
||||
<Icon src={copied ? checkIcon : copyIcon} size={16} />
|
||||
</button>
|
||||
</li>
|
||||
{detected && <li>{t(`app.step_updates.${detected}`)}</li>}
|
||||
</ol>
|
||||
)}
|
||||
{primary?.sha256 && (
|
||||
<details className={s.details}>
|
||||
<summary>{t('app.checksum')}</summary>
|
||||
<code>{primary.sha256}</code>
|
||||
</details>
|
||||
)}
|
||||
{others.length > 0 && (
|
||||
<div className={s.otherClients}>
|
||||
<p className={s.otherTitle}>{t('app.other')}</p>
|
||||
{others.map((id) => {
|
||||
const file = files[id]!;
|
||||
return (
|
||||
<a key={id} className={s.downloadAlt} href={file.download_url} download={file.filename}>
|
||||
{t(`app.download.${id}`)}
|
||||
<span>
|
||||
{t('app.version', { version: data.version })} · {formatBytes(file.size, i18n.language)}
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{!primary && others.length === 0 && <div className={s.hint}>{t('app.unavailable')}</div>}
|
||||
</>
|
||||
) : (
|
||||
<div className={s.hint}>{t('app.unavailable')}</div>
|
||||
@@ -68,3 +90,16 @@ export function AppDownloadPage() {
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function DownloadButton({ client, href, meta }: { client: AppClient; href: string; meta: string }) {
|
||||
const { t } = useTranslation('auth');
|
||||
return (
|
||||
<a className={s.download} href={href} download>
|
||||
<Icon src={downloadIcon} size={22} />
|
||||
<span>
|
||||
{t(`app.download.${client}`)}
|
||||
<span className={s.downloadMeta}>{meta}</span>
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -47,7 +47,7 @@ export function LoginPage() {
|
||||
{desktop ? null : (
|
||||
<>
|
||||
{' · '}
|
||||
<a href="/android">{t('app.link')}</a>
|
||||
<a href="/download">{t('app.link')}</a>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { detectAppClient, otherAppClients } from './platform';
|
||||
|
||||
describe('detectAppClient', () => {
|
||||
it('picks the native client from the user agent', () => {
|
||||
expect(detectAppClient('Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36')).toBe('android');
|
||||
expect(detectAppClient('Mozilla/5.0 (Windows NT 10.0; Win64; x64)')).toBe('windows');
|
||||
expect(detectAppClient('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)')).toBe('macos');
|
||||
expect(detectAppClient('Mozilla/5.0 (X11; Linux x86_64)')).toBe('linux');
|
||||
expect(detectAppClient('Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)')).toBeNull();
|
||||
});
|
||||
|
||||
it('lists the remaining platforms after the detected one', () => {
|
||||
expect(otherAppClients('windows')).toEqual(['macos', 'linux', 'android']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
export type AppClient = 'android' | 'windows' | 'linux' | 'macos';
|
||||
|
||||
const ORDER: AppClient[] = ['windows', 'macos', 'linux', 'android'];
|
||||
|
||||
/** Клиент, который стоит предложить первым по User-Agent. На iOS приложения нет. */
|
||||
export function detectAppClient(ua = typeof navigator === 'undefined' ? '' : navigator.userAgent): AppClient | null {
|
||||
if (/Android/i.test(ua)) return 'android';
|
||||
if (/iPhone|iPad|iPod/i.test(ua)) return null;
|
||||
if (/Windows/i.test(ua)) return 'windows';
|
||||
if (/Mac OS X|Macintosh/i.test(ua)) return 'macos';
|
||||
if (/Linux|X11/i.test(ua)) return 'linux';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function otherAppClients(primary: AppClient | null): AppClient[] {
|
||||
return ORDER.filter((c) => c !== primary);
|
||||
}
|
||||
@@ -116,6 +116,28 @@ export function fakeApi(initial: FakeUser[]) {
|
||||
min_android_version_code: 0,
|
||||
bootstrap_required: [...users.values()].some((u) => u.must_change_credentials),
|
||||
});
|
||||
case 'GET /api/v1/app/clients':
|
||||
return json({
|
||||
version: '1.3.0',
|
||||
version_code: 1_030_099,
|
||||
notes_md: '',
|
||||
clients: [
|
||||
{ client: 'windows', filename: 'pipu-music_1.3.0_x64-setup.exe', size: 20_000_000, download_url: '/api/v1/app/download/windows/1.3.0' },
|
||||
{ client: 'linux', filename: 'pipu-music_1.3.0_amd64.AppImage', size: 18_000_000, download_url: '/api/v1/app/download/linux/1.3.0' },
|
||||
{ client: 'macos', filename: 'pipu-music_1.3.0_aarch64.dmg', size: 22_000_000, download_url: '/api/v1/app/download/macos/1.3.0' },
|
||||
{ client: 'android', filename: 'pipu-music-1.3.0.apk', size: 12_000_000, sha256: 'ab'.repeat(32), download_url: '/api/v1/app/download/android/1.3.0' },
|
||||
],
|
||||
});
|
||||
case 'GET /api/v1/app/android/latest':
|
||||
return json({
|
||||
version: '1.3.0',
|
||||
version_code: 1_030_099,
|
||||
notes_md: '',
|
||||
size: 12_000_000,
|
||||
sha256: 'ab'.repeat(32),
|
||||
download_url: '/api/v1/app/android/download/1.3.0',
|
||||
min_supported: 0,
|
||||
});
|
||||
case 'POST /api/v1/auth/login': {
|
||||
const u = [...users.values()].find((x) => x.username === body?.username && x.password === body?.password);
|
||||
if (!u) return problem(401, 'auth.invalid_credentials');
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Suspense, useCallback, useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAuthState, useSession, useUser } from '../app/session';
|
||||
import { useDesktop } from '../desktop';
|
||||
import { useCollectionCounts } from '../entities/collection';
|
||||
import {
|
||||
moreIcon,
|
||||
@@ -25,7 +26,7 @@ import {
|
||||
dislikeFillIcon,
|
||||
dislikeIcon,
|
||||
settingsIcon,
|
||||
androidIcon,
|
||||
downloadIcon,
|
||||
chevronRightIcon,
|
||||
storageFillIcon,
|
||||
storageIcon, inboxFillIcon, inboxIcon, myUploadsFillIcon, myUploadsIcon, uploadFillIcon, uploadIcon, tuneFillIcon, tuneIcon} from '../shared/ui/icons';
|
||||
@@ -155,6 +156,7 @@ function Shell() {
|
||||
const { t } = useTranslation();
|
||||
const user = useUser();
|
||||
const session = useSession();
|
||||
const desktop = useDesktop();
|
||||
const navigate = useNavigate();
|
||||
const name = user.display_name ?? user.username;
|
||||
const staff = user.role !== 'user';
|
||||
@@ -252,7 +254,9 @@ function Shell() {
|
||||
}
|
||||
items={[
|
||||
{ label: t('profile.settings'), icon: settingsIcon, onSelect: () => void navigate({ to: '/settings' }) },
|
||||
{ label: t('profile.android_app'), icon: androidIcon, onSelect: () => void navigate({ to: '/android' }) },
|
||||
...(!desktop
|
||||
? [{ label: t('profile.download_apps'), icon: downloadIcon, onSelect: () => void navigate({ to: '/download' }) }]
|
||||
: []),
|
||||
{ label: t('profile.logout'), icon: logoutIcon, danger: true, onSelect: () => void session.logout() },
|
||||
]}
|
||||
/>
|
||||
|
||||
Reference in new issue
Block a user