fix: repair Android startup and improve mobile web layouts

This commit is contained in:
mrleo1nid committed 2026-10-21 17:14:05 +03:00
1 parent 4c2286e70d
commit b5b044d70c
30 files changed
+597 -128

No files matched your search

+10
View File
@@ -0,0 +1,10 @@
appId: app.pipu.music
---
- launchApp:
clearState: true
- assertVisible: "Адрес сервера|Server address"
- stopApp
- launchApp
- assertVisible: "Адрес сервера|Server address"
- openLink: "pipu://album/1"
- assertVisible: "Адрес сервера|Server address"
+3 -1
View File
@@ -30,7 +30,9 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
'expo-secure-store',
// Сервер часто живёт в домашней сети без TLS (http://192.168.1.10:8080), а release-сборка
// Android по умолчанию запрещает незашифрованный HTTP.
['expo-build-properties', { android: { usesCleartextTraffic: true } }],
// Распаковываем .so при установке: SoLoader на x86_64 с ARM-трансляцией ищет
// внутри APK lib/x86_64 вместо arm64-v8a и не находит libreactnative.so.
['expo-build-properties', { android: { usesCleartextTraffic: true, useLegacyPackaging: true } }],
// APK только под arm64-v8a — та же ABI, под которую собираются нативные модули (plugins/withArm64Only.js).
'./plugins/withArm64Only',
],
+21 -14
View File
@@ -1,6 +1,6 @@
import { createI18n, pickLocale, SUPPORTED_LOCALES, type Locale } from '@pipu/i18n';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Stack, useRouter, useSegments } from 'expo-router';
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import type { i18n as I18n } from 'i18next';
import { useEffect, useState } from 'react';
@@ -54,8 +54,6 @@ const isLocale = (l: string): l is Locale => (SUPPORTED_LOCALES as readonly stri
function Gate() {
const { server, session, ready } = useSessionCtx();
const auth = useAuthState();
const segments = useSegments();
const router = useRouter();
const { i18n } = useTranslation();
const user = auth.status === 'signed_in' ? auth.user : null;
const system = useColorScheme();
@@ -79,17 +77,6 @@ function Gate() {
return () => sub.remove();
}, []);
useEffect(() => {
if (!ready) return;
const first = segments[0] as string | undefined;
let target: string | null = null;
if (!server) target = first === 'connect' ? null : '/connect';
else if (!user) target = first === 'login' || first === 'connect' ? null : '/login';
else if (user.must_change_credentials || user.must_change_password) target = first === 'setup' ? null : '/setup';
else if (first === 'connect' || first === 'login' || first === 'setup') target = '/';
if (target) router.replace(target as never);
}, [ready, server, user?.id, user?.must_change_credentials, user?.must_change_password, segments]);
if (!ready) return <Splash />;
return (
<ThemeContext.Provider value={theme}>
@@ -101,9 +88,29 @@ function Gate() {
function Screens() {
const colors = useColors();
const { server, session } = useSessionCtx();
const auth = useAuthState();
const connected = !!server && !!session;
const user = connected && auth.status === 'signed_in' ? auth.user : null;
const needsSetup = !!user && (user.must_change_credentials || user.must_change_password);
return (
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: colors.bg } }}>
{/* Guards exclude screens before render: an effect redirect runs after useSession/useUser throw. */}
<Stack.Protected guard={!connected}>
<Stack.Screen name="connect" />
</Stack.Protected>
<Stack.Protected guard={connected && !user}>
<Stack.Screen name="login" />
</Stack.Protected>
<Stack.Protected guard={needsSetup}>
<Stack.Screen name="setup" />
</Stack.Protected>
<Stack.Protected guard={!!user && !needsSetup}>
<Stack.Screen name="(tabs)" />
<Stack.Screen name="album/[id]" />
<Stack.Screen name="artist/[id]" />
<Stack.Screen name="player" options={{ presentation: 'modal', animation: 'slide_from_bottom' }} />
</Stack.Protected>
</Stack>
);
}
+119
View File
@@ -0,0 +1,119 @@
import { expect, test, type Page } from '@playwright/test';
async function login(page: Page, username: string) {
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
await page.goto('/login');
await page.getByLabel('Логин', { exact: true }).fill(username);
await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill(`${username}-password`);
await page.getByRole('button', { name: 'Войти', exact: true }).click();
await expect(page.getByRole('button', { name: 'Ещё', exact: true })).toBeVisible();
}
async function fits(page: Page) {
await expect.poll(() => page.evaluate(() => {
const main = document.querySelector('main')!;
return Math.max(document.documentElement.scrollWidth - innerWidth, main.scrollWidth - main.clientWidth);
})).toBeLessThanOrEqual(1);
}
for (const [width, username] of [[320, 'mobile-admin'], [390, 'moderator'], [430, 'alice']] as const) {
test(`mobile ${width}: ${username} navigation, forms and search`, async ({ page }, info) => {
test.setTimeout(60_000);
await page.setViewportSize({ width, height: 844 });
await login(page, username);
await fits(page);
const bottom = page.getByRole('navigation', { name: 'Навигация' }).filter({ has: page.getByRole('button', { name: 'Ещё', exact: true }) });
await expect(bottom.getByRole('link')).toHaveCount(3);
await page.getByRole('button', { name: 'Ещё', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Навигация' });
await expect(dialog).toBeVisible();
await expect(dialog.getByRole('link', { name: 'Дизлайки', exact: true })).toBeVisible();
if (username !== 'alice') {
await expect(dialog.getByRole('link', { name: 'Пользователи', exact: true })).toBeVisible();
await expect(dialog.getByRole('link', { name: 'Очередь', exact: true })).toBeVisible();
await expect(dialog.getByRole('link', { name: 'Хранилища', exact: true })).toHaveCount(username === 'mobile-admin' ? 1 : 0);
await dialog.getByRole('link', { name: 'Пользователи', exact: true }).click();
await expect(dialog).not.toBeVisible();
await expect(page.getByRole('heading', { name: 'Пользователи', exact: true })).toBeVisible();
await fits(page);
await page.screenshot({ path: info.outputPath('users.png') });
await page.getByRole('button', { name: /Действия.*alice/ }).click();
const menu = page.getByRole('menu');
await expect(menu).toBeVisible();
const rect = await menu.boundingBox();
expect(rect!.x).toBeGreaterThanOrEqual(0);
expect(rect!.x + rect!.width).toBeLessThanOrEqual(width);
await page.keyboard.press('Escape');
await page.getByRole('button', { name: 'Создать пользователя', exact: true }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await page.screenshot({ path: info.outputPath('create-user.png') });
await page.getByRole('button', { name: 'Закрыть', exact: true }).click();
} else {
await expect(dialog.getByRole('link', { name: 'Пользователи', exact: true })).toHaveCount(0);
await page.getByRole('button', { name: 'Закрыть', exact: true }).click();
}
await page.getByRole('button', { name: 'Меню профиля' }).click();
await page.getByRole('menuitem', { name: 'Настройки', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Настройки', exact: true })).toBeVisible();
await fits(page);
await page.screenshot({ path: info.outputPath('settings.png') });
for (const name of ['Слева', 'По центру', 'Справа']) {
await page.getByRole('button', { name, exact: true }).click();
await page.getByRole('combobox', { name: 'Поиск', exact: true }).fill('кино');
await expect(page.getByRole('option', { name: /Кино/ }).first()).toBeVisible();
await fits(page);
await page.getByRole('combobox', { name: 'Поиск', exact: true }).press('Escape');
}
if (username !== 'alice') {
await page.getByRole('button', { name: 'Ещё', exact: true }).click();
await page.getByRole('dialog').getByRole('link', { name: 'Очередь', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Очередь на добавление' })).toBeVisible();
await expect(page.locator('audio')).toBeVisible();
await fits(page);
await page.getByRole('checkbox', { name: /^Выбрать Длинное/ }).check();
await expect(page.getByRole('region', { name: 'Действия с выбранными' })).toBeVisible();
await fits(page);
await page.screenshot({ path: info.outputPath('upload-queue.png') });
await page.getByRole('region', { name: 'Действия с выбранными' }).getByRole('button', { name: 'Отклонить' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByRole('button', { name: 'Закрыть', exact: true }).click();
}
if (username === 'mobile-admin') {
await page.getByRole('button', { name: 'Ещё', exact: true }).click();
await page.getByRole('dialog').getByRole('link', { name: 'Хранилища', exact: true }).click();
await expect(page.getByRole('article', { name: 'Музыкальное хранилище' })).toBeVisible();
await fits(page);
await page.getByRole('button', { name: 'Шаблоны путей', exact: true }).click();
await page.getByRole('button', { name: 'Добавить шаблон', exact: true }).click();
await fits(page);
await page.screenshot({ path: info.outputPath('patterns.png') });
}
});
}
test('mobile player keeps the island above navigation and exposes the queue', async ({ page }, info) => {
await page.setViewportSize({ width: 320, height: 740 });
await login(page, 'alice');
const box = page.getByRole('combobox', { name: 'Поиск', exact: true });
await box.fill('кино');
await box.press('Enter');
await page.getByRole('link', { name: /Группа крови/ }).first().click();
await page.getByRole('listitem').first().click();
const player = page.getByRole('contentinfo', { name: 'Плеер' });
await expect(player.getByRole('button', { name: 'Очередь', exact: true })).toBeVisible();
expect((await player.getByText('Группа крови', { exact: true }).boundingBox())!.width).toBeGreaterThan(40);
await fits(page);
await page.getByRole('button', { name: 'Меню профиля' }).click();
await page.getByRole('menuitem', { name: 'Настройки', exact: true }).click();
await page.getByRole('button', { name: 'Остров', exact: true }).click();
const nav = page.getByRole('navigation').filter({ has: page.getByRole('button', { name: 'Ещё', exact: true }) });
await expect.poll(async () => {
const p = await player.boundingBox();
const n = await nav.boundingBox();
return p!.y + p!.height <= n!.y + 1;
}).toBe(true);
await player.getByRole('button', { name: 'Очередь', exact: true }).click();
await expect(page.getByRole('complementary', { name: 'Очередь', exact: true })).toBeVisible();
await page.screenshot({ path: info.outputPath('player-queue.png') });
});
+4
View File
@@ -29,6 +29,10 @@ test('4. search suggest opens the artist and Enter opens search', async ({ page
await expect(page.getByRole('heading', { name: 'Кино' })).toBeVisible();
await box.fill('кино');
// A suggestion under the pointer must not become a keyboard selection.
const suggestion = page.getByRole('option', { name: /Кино/ }).first();
await suggestion.hover();
await expect(suggestion).toHaveAttribute('aria-selected', 'false');
await box.press('Enter');
await expect(page).toHaveURL(/\/search\?q=/);
});
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="ru" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="color-scheme" content="dark light" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<title>pipu</title>
+8
View File
@@ -270,3 +270,11 @@
display: none;
}
}
@media (max-width: 767px) {
.grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.track, .track:has(.trackAlbum) { gap: 8px; padding: 6px 4px; }
.track:has(.lead), .track:has(.lead):has(.trackAlbum) { grid-template-columns: 24px 40px minmax(0, 1fr) auto; }
.end { gap: 4px; }
.like { opacity: 1; width: 44px; height: 44px; }
}
+6 -6
View File
@@ -181,7 +181,7 @@ export function UsersPage() {
{recsDone && <p className={s.ok}>{t('users.recs_done', { username: recsDone })}</p>}
<div className={s.tableWrap}>
<table className={s.table}>
<table className={cx(s.table, s.mobileCards)}>
<thead>
<tr>
<th>{t('users.col_user')}</th>
@@ -197,7 +197,7 @@ export function UsersPage() {
const st = status(u);
return (
<tr key={u.id}>
<td>
<td data-label={t('users.col_user')}>
<div className={s.userCell}>
<Avatar name={u.display_name ?? u.username} />
<div>
@@ -209,17 +209,17 @@ export function UsersPage() {
</div>
</div>
</td>
<td>
<td data-label={t('users.col_role')}>
<span className={cx(s.badge, s[`role_${u.role}`])}>{t(`common:roles.${u.role}`)}</span>
{u.reviews_uploads && <span className={cx(s.badge, s.role_moderator)}>{t('users.review_uploads')}</span>}
</td>
<td>
<td data-label={t('users.col_status')}>
<span className={cx(s.status, s[`status_${st}`])}>{t(`users.status_${st}`)}</span>
</td>
<td className={s.muted}>
<td className={s.muted} data-label={t('users.col_last_login')}>
{u.last_login_at ? formatRelative(u.last_login_at, i18n.language) : t('users.never')}
</td>
<td>
<td className={s.cardAction}>
{items.length > 0 && (
<Menu label={t('users.actions', { username: u.username })} trigger={<Icon src={moreIcon} />} items={items} />
)}
@@ -320,3 +320,14 @@
gap: 10px;
cursor: pointer;
}
@media (max-width: 767px) {
.card { padding: 16px; }
.cardActions > button { flex: 1 1 auto; }
.retryRow { flex-wrap: wrap; gap: 10px; }
.patternRow { grid-template-columns: 20px minmax(0, 1fr); }
.patternTools { grid-column: 2; margin-top: 0 !important; height: auto; flex-wrap: wrap; }
.patternTools .check { flex-basis: 100%; min-height: 44px; }
.patternTools > button { width: 44px; min-width: 44px; padding: 0; }
.dir { min-height: 44px; height: auto; padding: 8px; overflow-wrap: anywhere; }
}
+7
View File
@@ -218,3 +218,10 @@
height: 44px;
border-radius: var(--radius-pill);
}
@media (max-width: 767px) {
.heroText { min-width: 0; }
.colTitle { font-size: clamp(24px, 7vw, 34px); overflow-wrap: anywhere; }
.underlineTabs { overflow-x: auto; }
.underlineTab { flex-shrink: 0; min-height: 44px; }
}
+8
View File
@@ -1,5 +1,7 @@
.home {
min-height: 100%;
width: 100%;
overflow-x: clip;
display: flex;
flex-direction: column;
align-items: center;
@@ -107,3 +109,9 @@
.chipOn > span {
color: #fff;
}
@media (max-width: 767px) {
.home { justify-content: flex-start; gap: 20px; padding: 8px 0 20px; }
.chip { min-height: 44px; height: auto; padding: 10px 14px; }
.orbLabel { font-size: clamp(18px, 5vw, 22px); }
}
+26
View File
@@ -341,3 +341,29 @@
font-size: 12px;
color: var(--text3);
}
@media (max-width: 767px) {
.page, .section, .head > *, .toolbar > *, .settingRow > * { min-width: 0; max-width: 100%; }
.page { gap: 20px; overflow-wrap: anywhere; }
.head h1 { font-size: 24px; }
.section { padding: 16px; }
.row > :first-child, .search { min-width: 0; flex-basis: 100%; max-width: none; }
.settingRow { align-items: flex-start; gap: 10px; }
.settingRow > :first-child { flex-basis: 100%; }
.actions { flex-wrap: wrap; justify-content: flex-start; }
.actions > * { flex: 1 1 auto; }
.chip { min-height: 44px; height: auto; padding: 8px 14px; }
.select { max-width: 100%; min-width: 0; font-size: 16px; }
.session { flex-wrap: wrap; overflow-wrap: anywhere; }
.tableWrap { min-width: 0; max-width: 100%; }
.mobileCards, .mobileCards tbody { display: block; }
.mobileCards thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
.mobileCards tr { display: flex; flex-direction: column; padding: 14px; border-bottom: 1px solid var(--line); gap: 12px; }
.mobileCards tr:last-child { border-bottom: 0; }
.mobileCards td { display: flex; flex-wrap: wrap; gap: 6px 10px; height: auto; padding: 0; border: 0; white-space: normal; }
.mobileCards td::before { content: attr(data-label); flex-basis: 100%; font-size: 12px; color: var(--text3); }
.mobileCards .userCell { min-width: 0; }
.mobileCards .userCell > div { min-width: 0; overflow-wrap: anywhere; }
.mobileCards .cardAction { align-self: flex-end; }
.cardAction::before { display: none; }
}
@@ -320,3 +320,22 @@
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 767px) {
.queueRow { grid-template-columns: 24px minmax(0, 1fr); gap: 10px; padding: 12px; }
.queueRow > :nth-child(3), .queueRow > .rowActions { grid-column: 2; }
.rowActions { flex-wrap: wrap; min-width: 0; }
.rowActions audio { width: 100%; min-width: 0; max-width: 100%; }
.meta { overflow-wrap: anywhere; }
.bulk { bottom: 0; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
.bulk > span:nth-child(2) { display: none; }
.bulk > button { min-width: 0; font-size: 13px; padding: 10px 8px; }
.grid, .settingsGrid { grid-template-columns: minmax(0, 1fr); }
.drop { padding: 20px 12px; }
.dropActions > * { flex: 1 1 auto; }
.quota { min-width: 0; max-width: 100%; }
.file { grid-template-columns: minmax(0, 1fr) auto; gap: 8px; }
.file > :last-child { grid-column: 2; grid-row: 1; }
.file > .size { grid-column: 1; }
.textarea { font-size: 16px; }
}
+20
View File
@@ -196,6 +196,7 @@
z-index: 50;
display: grid;
place-items: center;
grid-template-columns: minmax(0, 1fr);
padding: 16px;
background: var(--overlay);
animation: pipuIn 0.2s ease-out;
@@ -317,3 +318,22 @@
color: #fff;
font-weight: 700;
}
@media (max-width: 767px) {
.button { white-space: normal; height: auto; min-height: 44px; padding: 10px 14px; }
.iconButton { width: 44px; height: 44px; flex: none; }
.input, .select { font-size: 16px; min-width: 0; }
.segmented { flex-wrap: wrap; max-width: 100%; }
.segmented button { min-height: 44px; height: auto; padding: 8px 12px; }
.overlay { padding: 12px; z-index: 60; }
.dialog { min-width: 0; overflow-wrap: anywhere; max-height: calc(100dvh - 24px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px)); }
.dialogHead { padding: 16px 16px 0; align-items: flex-start; }
.dialogHead h2 { overflow-wrap: anywhere; font-size: 18px; }
.dialogBody { padding: 16px; min-width: 0; }
.field, .inputWrap { min-width: 0; }
.dialogFoot { padding: 0 16px 16px; flex-wrap: wrap; }
.dialogFoot > * { flex: 1 1 auto; }
.menu { position: fixed; inset: auto 12px calc(12px + env(safe-area-inset-bottom, 0px));
width: auto; min-width: 0; max-width: none; max-height: 75dvh; overflow-y: auto; z-index: 70; }
.menuItem { min-height: 44px; height: auto; padding: 12px; white-space: normal; overflow-wrap: anywhere; }
}
+38 -20
View File
@@ -8,6 +8,7 @@ import {
type KeyboardEvent as ReactKeyboardEvent,
type ReactNode,
} from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { closeIcon, visibilityIcon, visibilityOffIcon } from './icons';
@@ -162,6 +163,18 @@ export function Dialog({
(first ?? ref.current)?.focus();
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
if (e.key === 'Tab') {
const controls = [...(ref.current?.querySelectorAll<HTMLElement>(
'a[href], button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex="0"]',
) ?? [])].filter((el) => el.getClientRects().length > 0);
const first = controls[0];
const last = controls[controls.length - 1];
if (e.shiftKey && (document.activeElement === first || document.activeElement === ref.current)) {
e.preventDefault(); last?.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault(); first?.focus();
}
}
};
document.addEventListener('keydown', onKey);
return () => {
@@ -169,7 +182,7 @@ export function Dialog({
prev?.focus();
};
}, [onClose]);
return (
return createPortal(
<div className={s.overlay} onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
<div ref={ref} className={s.dialog} role="dialog" aria-modal="true" aria-labelledby={titleId} tabIndex={-1}>
<div className={s.dialogHead}>
@@ -181,7 +194,8 @@ export function Dialog({
<div className={s.dialogBody}>{children}</div>
{footer && <div className={s.dialogFoot}>{footer}</div>}
</div>
</div>
</div>,
document.body,
);
}
@@ -205,17 +219,18 @@ export function Menu({
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (!ref.current?.contains(e.target as Node)) setOpen(false);
if (!ref.current?.contains(e.target as Node) && !menuRef.current?.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false);
};
document.addEventListener('mousedown', onDown);
document.addEventListener('keydown', onKey);
ref.current?.querySelector<HTMLElement>('[role="menuitem"]')?.focus();
menuRef.current?.querySelector<HTMLElement>('[role="menuitem"]')?.focus();
return () => {
document.removeEventListener('mousedown', onDown);
document.removeEventListener('keydown', onKey);
@@ -224,25 +239,13 @@ export function Menu({
const onMenuKey = (e: ReactKeyboardEvent) => {
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
e.preventDefault();
const els = [...(ref.current?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? [])];
const els = [...(menuRef.current?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? [])];
const i = els.indexOf(document.activeElement as HTMLElement);
const next = e.key === 'ArrowDown' ? (i + 1) % els.length : (i - 1 + els.length) % els.length;
els[next]?.focus();
};
return (
<div className={s.menuWrap} ref={ref}>
<button
type="button"
className={triggerClassName ?? s.iconButton}
aria-label={label}
aria-haspopup="menu"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
>
{trigger}
</button>
{open && (
<div className={cx(s.menu, align === 'start' && s.menuStart)} role="menu" aria-label={label} onKeyDown={onMenuKey}>
const menu = (
<div ref={menuRef} className={cx(s.menu, align === 'start' && s.menuStart)} role="menu" aria-label={label} onKeyDown={onMenuKey}>
{header}
{items.map((it) => (
<button
@@ -260,7 +263,22 @@ export function Menu({
</button>
))}
</div>
)}
);
return (
<div className={s.menuWrap} ref={ref}>
<button
type="button"
className={triggerClassName ?? s.iconButton}
aria-label={label}
aria-haspopup="menu"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
>
{trigger}
</button>
{open && (window.matchMedia?.('(max-width: 767px)').matches
? createPortal(menu, document.body)
: menu)}
</div>
);
}
+7
View File
@@ -1,14 +1,21 @@
import { fakeApi, makeUser } from './fakeApi';
import { mobileFixtures } from './mobileFixtures';
/** Подменяет fetch на фейковый API. Включается только при сборке с VITE_E2E=1. */
export function installE2EFetch() {
const api = fakeApi([
makeUser('admin', 'admin', { must_change_credentials: true }),
makeUser('alice', 'user'),
makeUser('mobile-admin', 'admin', { reviews_uploads: true }),
makeUser('moderator', 'moderator', { reviews_uploads: true }),
]);
const orig = window.fetch.bind(window);
window.fetch = async (input, init) => {
const req = new Request(input, init);
const path = new URL(req.url, window.location.origin).pathname;
if (req.method === 'GET' && path in mobileFixtures) {
return new Response(JSON.stringify(mobileFixtures[path]), { headers: { 'Content-Type': 'application/json' } });
}
if (new URL(req.url, window.location.origin).pathname.startsWith('/api/')) return api.fetch(req);
return orig(input, init);
};
+31
View File
@@ -0,0 +1,31 @@
import type { components } from '@pipu/api-client';
const source: components['schemas']['Source'] = {
id: '00000000-0000-4000-8000-000000000099', name: 'Музыкальное хранилище', type: 'local',
nfs_server: null, nfs_export: null, nfs_options: '', local_path: 'music/very-long-library-folder/collection',
tag_priority: 'tags_first', scan_interval_minutes: null, accepts_uploads: true,
write_template: null, status: 'mounted', status_message: null, last_scan_at: null,
created_at: '2026-01-01T00:00:00Z', scan: null,
};
const file: components['schemas']['UploadFile'] = {
id: '00000000-0000-4000-8000-000000000098', batch_id: '00000000-0000-4000-8000-000000000097',
batch_comment: 'Альбом для проверки', author: { id: '00000000-0000-4000-8000-000000000096', username: 'moderator' },
status: 'pending_review', original_name: 'Длинное_название_трека_для_проверки_переноса.flac',
client_rel_path: 'music/album/Длинное_название_трека_для_проверки_переноса.flac', size_bytes: 42000000,
received_bytes: 42000000, format: 'FLAC', codec: 'flac', bitrate: 1000000, sample_rate: 44100, bit_depth: 16,
duration_ms: 200000, tags: {}, metadata: null, metadata_edited: false, matched_artist_id: null,
matched_album_id: null, duplicate_of_track_id: null, duplicate_level: null, possible_duplicate: true,
quality_upgrade: false, target_source_id: source.id, target_rel_path: null, status_reason: null,
reviewed_at: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', tus_url: null,
stream_url: '/api/v1/stream/mobile-preview',
};
/** Populated staff screens for browser layout checks; loaded only by the E2E bridge. */
export const mobileFixtures: Record<string, unknown> = {
'/api/v1/admin/sources': { items: [source] },
[`/api/v1/admin/sources/${source.id}`]: source,
[`/api/v1/admin/sources/${source.id}/patterns`]: { patterns: [] },
'/api/v1/admin/uploads': { items: [file], next_cursor: null },
'/api/v1/uploads/quota': { limit_bytes: 10000000000, used_bytes: 42000000, per_user_limit_bytes: null,
user_used_bytes: 42000000, max_file_bytes: 1000000000 },
};
+60 -26
View File
@@ -2,13 +2,14 @@
--side-w: 240px;
position: relative;
display: grid;
grid-template-columns: var(--side-w) 1fr;
grid-template-rows: 60px 1fr 88px;
grid-template-columns: var(--side-w) minmax(0, 1fr);
grid-template-rows: 60px minmax(0, 1fr) 88px;
grid-template-areas:
'top top'
'side content'
'player player';
height: 100%;
height: 100dvh;
}
.shell > :where(:not([aria-hidden])) {
@@ -18,7 +19,7 @@
/* Плеер-остров: плавает над контентом, контенту — запас снизу (docs/design.md#4). */
.island {
grid-template-rows: 60px 1fr;
grid-template-rows: 60px minmax(0, 1fr);
grid-template-areas:
'top top'
'side content';
@@ -228,6 +229,8 @@
.content {
grid-area: content;
min-width: 0;
min-height: 0;
overflow-y: auto;
padding: 32px 36px;
animation: pipuIn 0.2s ease-out;
@@ -268,36 +271,67 @@
}
}
.mobileBottom { display: none; }
@media (max-width: 767px) {
.shell {
.shell, .shell.island {
--side-w: 0px;
grid-template-columns: 1fr;
grid-template-rows: 60px 1fr 72px 64px;
grid-template-areas:
'top'
'content'
'player'
'side';
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto minmax(0, 1fr) 72px calc(64px + env(safe-area-inset-bottom, 0px));
grid-template-areas: 'top' 'content' 'player' 'mobile';
}
.side {
flex-direction: row;
justify-content: space-around;
padding: 6px 8px;
border-right: 0;
.top {
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px 12px;
padding: calc(8px + env(safe-area-inset-top, 0px)) 16px 10px;
}
.topLeft, .topCenter, .topRight { display: contents; }
.logoLink { grid-area: 1 / 1; justify-self: start; }
.topRight > :last-child { grid-area: 1 / 2; justify-self: end; }
.topLeft > :not(.logoLink), .topCenter > *, .topRight > :not(:last-child) {
grid-column: 1 / -1;
grid-row: 2;
min-width: 0;
}
.profileButton { min-width: 44px; min-height: 44px; }
.profileHead { grid-template-columns: auto minmax(0, 1fr); }
.profileName, .profileMeta { white-space: normal; overflow-wrap: anywhere; }
.side { display: none; }
.mobileBottom {
grid-area: mobile;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 4px;
padding: 4px 8px calc(4px + env(safe-area-inset-bottom, 0px));
border-top: 1px solid var(--line);
background: var(--bar-a);
}
.navSection {
display: none;
.mobileBottom .navItem {
position: relative;
flex-direction: column;
gap: 3px;
height: 56px;
padding: 6px 2px;
font-size: 11px;
}
.navItem {
flex: 1;
.mobileBottom .navLabel { display: block; flex: none; max-width: 100%; }
.mobileDot {
position: absolute; top: 6px; right: 24%;
width: 8px; height: 8px; border-radius: 50%; background: var(--accent2);
}
.content {
padding: 20px 16px;
.mobileMenu { display: flex; flex-direction: column; gap: 4px; }
.mobileMenu .navItem {
justify-content: flex-start; height: auto; min-height: 48px; padding: 8px 12px; font-size: 14px;
}
.mobileMenu .navLabel, .mobileMenu .navCount, .mobileMenu .navBadge { display: block; }
.mobileMenu .navLabel { white-space: normal; }
.mobileMenu .navGroup, .mobileMenu .navGroupFolded { display: contents; }
.mobileMenu .navSection {
display: flex; margin: 12px 0 0; padding: 10px 12px 4px;
border: 0; pointer-events: none;
}
.mobileMenu .navSectionTitle { display: block; }
.mobileMenu .navChevron { display: none; }
.content, .island .content { padding: 20px 16px 24px; }
}
+82 -50
View File
@@ -1,12 +1,13 @@
import { unwrap } from '@pipu/core';
import { useQuery } from '@tanstack/react-query';
import { Link, Outlet, useNavigate } from '@tanstack/react-router';
import { Suspense, useState, type ReactNode } from 'react';
import { Suspense, useCallback, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuthState, useSession, useUser } from '../app/session';
import { useCollectionCounts } from '../entities/collection';
import {
moreIcon,
auditFillIcon,
auditIcon,
groupFillIcon,
@@ -27,7 +28,7 @@ import {
chevronRightIcon,
storageFillIcon,
storageIcon, inboxFillIcon, inboxIcon, myUploadsFillIcon, myUploadsIcon, uploadFillIcon, uploadIcon, tuneFillIcon, tuneIcon} from '../shared/ui/icons';
import { Avatar, cx, Icon, Logo, Menu } from '../shared/ui/ui';
import { Avatar, cx, Dialog, Icon, Logo, Menu } from '../shared/ui/ui';
import { LanguageSwitch } from './LanguageSwitch';
import { PlayerProvider, usePlayer, usePlayerState } from '../player/context';
import { Ambient } from './Ambient';
@@ -65,7 +66,7 @@ function NavLink({
badge?: number; // яркий бейдж (ждут решения)
}) {
return (
<Link to={to} className={s.navItem} activeOptions={{ exact: to === '/' }}>
<Link to={to} aria-label={label} title={label} className={s.navItem} activeOptions={{ exact: to === '/' }}>
{({ isActive }) => (
<>
<Icon src={isActive ? activeIcon : icon} className={s.navIcon} />
@@ -97,12 +98,14 @@ function NavSection({
badge,
children,
defaultFolded = false,
mobile = false,
}: {
id: string;
title: string;
badge?: number;
children: ReactNode;
defaultFolded?: boolean;
mobile?: boolean;
}) {
const [folded, setFolded] = useState(() => {
if (readIds(FOLDED_KEY).includes(id)) return true;
@@ -123,12 +126,16 @@ function NavSection({
};
return (
<>
{mobile ? (
<h3 className={s.navSection}><span className={s.navSectionTitle}>{title}</span></h3>
) : (
<button type="button" className={s.navSection} aria-expanded={!folded} onClick={toggle}>
<span className={s.navSectionTitle}>{title}</span>
{folded && badge ? <span className={s.navBadge}>{badge > 99 ? '99+' : badge}</span> : null}
<Icon src={chevronRightIcon} size={16} className={cx(s.navChevron, !folded && s.navChevronOpen)} />
</button>
<div className={cx(s.navGroup, folded && s.navGroupFolded)}>{children}</div>
)}
<div className={cx(s.navGroup, !mobile && folded && s.navGroupFolded)}>{children}</div>
</>
);
}
@@ -156,6 +163,60 @@ function Shell() {
const reviews = user.reviews_uploads;
const pending = usePendingUploads(reviews);
const unseen = useUnseenUploads(staff);
const [mobileOpen, setMobileOpen] = useState(false);
const closeMobile = useCallback(() => setMobileOpen(false), []);
const navigation = (mobile = false) => (
<>
<NavLink to="/" label={t('nav.home')} icon={homeIcon} activeIcon={homeFillIcon} />
<NavSection mobile={mobile} id="collection" title={t('nav.collection')}>
<NavLink to="/collection/history" label={t('nav.history')} icon={historyIcon} activeIcon={historyFillIcon} />
<NavLink to="/collection/likes" label={t('nav.likes')} icon={likeIcon} activeIcon={likeFillIcon} count={counts.likes} />
<NavLink
to="/collection/dislikes"
label={t('nav.dislikes')}
icon={dislikeIcon}
activeIcon={dislikeFillIcon}
count={counts.dislikes}
/>
</NavSection>
{!staff && reviews && (
<NavLink
to="/admin/uploads"
label={t('nav.upload_queue')}
icon={inboxIcon}
activeIcon={inboxFillIcon}
badge={pending}
/>
)}
{staff && (
<NavSection mobile={mobile} id="admin" title={t('nav.admin')} badge={reviews ? pending : undefined} defaultFolded>
<NavLink to="/admin/users" label={t('nav.users')} icon={groupIcon} activeIcon={groupFillIcon} />
{user.role === 'admin' && (
<NavLink to="/admin/sources" label={t('nav.sources')} icon={storageIcon} activeIcon={storageFillIcon} />
)}
<NavLink to="/admin/library" label={t('nav.library')} icon={libraryIcon} activeIcon={libraryFillIcon} />
<NavLink to="/uploads/new" label={t('nav.suggest')} icon={uploadIcon} activeIcon={uploadFillIcon} />
<NavLink to="/uploads/mine" label={t('nav.my_uploads')} icon={myUploadsIcon} activeIcon={myUploadsFillIcon} badge={unseen} />
{reviews && (
<NavLink
to="/admin/uploads"
label={t('nav.upload_queue')}
icon={inboxIcon}
activeIcon={inboxFillIcon}
badge={pending}
/>
)}
{user.role === 'admin' && (
<>
<NavLink to="/admin/settings" label={t('nav.admin_settings')} icon={tuneIcon} activeIcon={tuneFillIcon} />
<NavLink to="/admin/audit" label={t('nav.audit')} icon={auditIcon} activeIcon={auditFillIcon} />
</>
)}
</NavSection>
)}
</>
);
return (
<div className={cx(s.shell, island && s.island)}>
@@ -195,55 +256,26 @@ function Shell() {
</div>
</header>
<nav className={s.side} aria-label={t('nav.main')}>
<nav className={s.side} aria-label={t('nav.main')}>{navigation()}</nav>
<nav className={s.mobileBottom} aria-label={t('nav.main')}>
<NavLink to="/" label={t('nav.home')} icon={homeIcon} activeIcon={homeFillIcon} />
<NavSection id="collection" title={t('nav.collection')}>
<NavLink to="/collection/history" label={t('nav.history')} icon={historyIcon} activeIcon={historyFillIcon} />
<NavLink to="/collection/likes" label={t('nav.likes')} icon={likeIcon} activeIcon={likeFillIcon} count={counts.likes} />
<NavLink
to="/collection/dislikes"
label={t('nav.dislikes')}
icon={dislikeIcon}
activeIcon={dislikeFillIcon}
count={counts.dislikes}
/>
</NavSection>
{!staff && reviews && (
<NavLink
to="/admin/uploads"
label={t('nav.upload_queue')}
icon={inboxIcon}
activeIcon={inboxFillIcon}
badge={pending}
/>
)}
{staff && (
<NavSection id="admin" title={t('nav.admin')} badge={reviews ? pending : undefined} defaultFolded>
<NavLink to="/admin/users" label={t('nav.users')} icon={groupIcon} activeIcon={groupFillIcon} />
{user.role === 'admin' && (
<NavLink to="/admin/sources" label={t('nav.sources')} icon={storageIcon} activeIcon={storageFillIcon} />
)}
<NavLink to="/admin/library" label={t('nav.library')} icon={libraryIcon} activeIcon={libraryFillIcon} />
<NavLink to="/uploads/new" label={t('nav.suggest')} icon={uploadIcon} activeIcon={uploadFillIcon} />
<NavLink to="/uploads/mine" label={t('nav.my_uploads')} icon={myUploadsIcon} activeIcon={myUploadsFillIcon} badge={unseen} />
{reviews && (
<NavLink
to="/admin/uploads"
label={t('nav.upload_queue')}
icon={inboxIcon}
activeIcon={inboxFillIcon}
badge={pending}
/>
)}
{user.role === 'admin' && (
<>
<NavLink to="/admin/settings" label={t('nav.admin_settings')} icon={tuneIcon} activeIcon={tuneFillIcon} />
<NavLink to="/admin/audit" label={t('nav.audit')} icon={auditIcon} activeIcon={auditFillIcon} />
</>
)}
</NavSection>
)}
<NavLink to="/collection/likes" label={t('nav.likes')} icon={likeIcon} activeIcon={likeFillIcon} />
<button type="button" className={s.navItem} aria-label={t('nav.more')}
aria-haspopup="dialog" aria-expanded={mobileOpen} onClick={() => setMobileOpen(true)}>
<Icon src={moreIcon} className={s.navIcon} />
<span className={s.navLabel}>{t('nav.more')}</span>
{(pending ?? 0) + (unseen ?? 0) > 0 && <span className={s.mobileDot} />}
</button>
</nav>
{mobileOpen && (
<Dialog title={t('nav.main')} onClose={closeMobile}>
<nav className={s.mobileMenu} aria-label={t('nav.main')}
onClick={(e) => { if ((e.target as HTMLElement).closest('a')) closeMobile(); }}>
{navigation(true)}
</nav>
</Dialog>
)}
<main className={s.content}>
<Suspense fallback={null}>
+21
View File
@@ -297,3 +297,24 @@ a.source:hover {
.queueCurrent .title {
color: var(--accent2);
}
@media (max-width: 767px) {
.bar { grid-template-columns: minmax(0, 1fr) auto auto; gap: 6px; padding: 0 8px; }
.bar.island { position: relative; left: auto; bottom: auto; width: auto; height: auto;
transform: none; margin: 0 8px; border-radius: 16px; }
.empty { grid-column: 1 / -1; }
.placeholder { flex: none; }
.now { gap: 8px; }
.now > :first-child { width: 44px !important; height: 44px !important; }
.source { display: none; }
.controls { gap: 2px; }
.play, .icon { width: 44px; height: 44px; }
.right { display: flex; gap: 0; }
.right > :not(.queueButton) { display: none; }
.drawer, .drawerIsland { bottom: calc(144px + env(safe-area-inset-bottom, 0px));
max-height: calc(100dvh - 180px - env(safe-area-inset-bottom, 0px)); }
}
@media (max-width: 359px) {
.now > :first-child { display: none; }
}
+1 -1
View File
@@ -175,7 +175,7 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
<button
ref={queueButton}
type="button"
className={cx(s.icon, queueOpen && s.accent)}
className={cx(s.icon, s.queueButton, queueOpen && s.accent)}
aria-label={t('player.queue')}
aria-expanded={queueOpen}
aria-controls={queueOpen ? queueId : undefined}
+11
View File
@@ -135,6 +135,7 @@
cursor: pointer;
}
.option:hover,
.active {
background: var(--hover);
}
@@ -185,3 +186,13 @@
display: none;
}
}
@media (max-width: 767px) {
.box, .expanded { width: 100%; min-width: 0; }
.input { height: 44px; font-size: 16px; }
.box:not(.expanded) .input { padding: 0 40px; color: var(--text); }
.box:not(.expanded) .input::placeholder { color: var(--text3); }
.left .popover, .center .popover, .right .popover { width: 100%; left: 0; right: auto; transform: none; max-height: 55dvh; }
.option { min-height: 48px; }
.all { min-height: 44px; height: auto; padding: 10px; }
}
-1
View File
@@ -209,7 +209,6 @@ export function SearchBox({
aria-selected={i === active}
className={cx(s.option, i === active && s.active)}
onMouseDown={(e) => e.preventDefault()}
onMouseEnter={() => setActive(i)}
onClick={() => choose(i)}
>
<Cover album={it.cover} size={36} round={it.round} />
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+6
View File
@@ -23,6 +23,12 @@
32-битные устройства выбирали их и падали при старте (`couldn't find DSO to load: libexpo-modules-core.so`).
- Все `.so` выровнены по 16 КБ: устройства с Android 15+ и 16 КБ страницами памяти не загружают библиотеки
с выравниванием 4 КБ, приложение закрывается сразу после запуска.
- `expo-build-properties.android.useLegacyPackaging=true`: библиотеки сжаты в APK и распаковываются
при установке. Иначе SoLoader на x86_64-эмуляторе с ARM-трансляцией ищет `lib/x86_64` внутри APK
вместо `arm64-v8a` и падает с `couldn't find DSO to load: libreactnative.so`.
- Доступ к экранам ограничен через `Stack.Protected` до их отрисовки. Перенаправление из `useEffect`
выполнялось слишком поздно: первый запуск без сервера открывал главную и падал с `Error: no session`.
Регрессия первого запуска, повторного открытия и защищённой ссылки: `.maestro/startup.yaml`.
## Экраны
+50
View File
@@ -0,0 +1,50 @@
# Mobile Web Review — 2026-10-21 (1)
Scope: responsive web UI, especially administrator and moderator workflows. This is a focused mobile review, not a backend or native-app audit. The attached screenshot was unavailable locally; findings were verified against source and Chromium browser screenshots.
## Prior Reviews Summary
No previous review files existed. Carried forward: 0. Resolved prior issues: 0.
## Phase 1: Code Quality
### Correctness & Bugs
The fixed-size canvas halo could expand the home page horizontally. The shell grid also lacked explicit shrinking constraints. Fixed in the home and shell styles.
### SOLID / Performance / Code Quality
No additional actionable issues identified within this mobile UI scope. Desktop and mobile menus reuse the same role-filtered navigation definition.
## Phase 2: Logical Consistency
### State Management / Consistency
Staff navigation permissions are shared by both layouts. Mobile section headings no longer expose collapsed state while showing the links. Dialogs render through a body portal and keep keyboard focus inside; action menus include their portal contents in outside-click handling.
### Domain & Application Layer / Data Flow
No application or server contracts changed. Added typed fixtures are confined to the existing E2E bridge.
## Phase 3: UI/UX
Nine findings fixed below. Verified with browser screenshots and responsive tests at 320, 390 and 430 CSS pixels. Desktop search/bootstrap/settings scenarios remain covered. This does not substitute for physical iOS/Android keyboard and safe-area testing.
## Tasks
- [x] 1. [UX] All staff links were compressed into an unlabelled bottom row. Keep three primary destinations plus More; show all permitted destinations, labels and pending badges in a scrollable navigation dialog — `apps/web/src/widgets/AppShell.tsx`, `Shell` / `NavSection`.
- [x] 2. [Visual] Search and profile competed for narrow header space. Use a dedicated full-width mobile search row for every search-position preference, and constrain the suggestion popup — `apps/web/src/widgets/AppShell.module.css`, mobile `.top`; `apps/web/src/widgets/SearchBox.module.css`, mobile popup.
- [x] 3. [UX] The user table required lateral scrolling to reach actions. Present labelled user cards on phones and open action menus in a body portal — `apps/web/src/pages/admin/UsersPage.tsx`, users table; `apps/web/src/shared/ui/ui.tsx`, `Menu`.
- [x] 4. [Bug] Dialog minimum-content width could push the close button outside a 320 px viewport. Constrain overlay grid tracks, allow content to shrink/wrap, and wrap footer actions — `apps/web/src/shared/ui/ui.module.css`, `.overlay` / mobile `.dialog`; `apps/web/src/shared/ui/ui.tsx`, `Dialog`.
- [x] 5. [UX] Moderation metadata/actions flowed into mismatched grid columns and native audio controls could expand the row. Put metadata and actions in the content column, constrain audio width, and compact the sticky bulk actions — `apps/web/src/pages/uploads/uploads.module.css`, mobile queue and bulk rules.
- [x] 6. [UX] Path-pattern tools competed with the expression input. Move tools below the input with a full-width enabled label and aligned touch buttons — `apps/web/src/pages/admin/library.module.css`, mobile `.patternRow` / `.patternTools`.
- [x] 7. [Bug] The floating player could overlap the mobile bottom navigation and its queue button was hidden. Reserve a player row for both variants, expose queue access, constrain the drawer, and preserve readable track text at 320 px — `apps/web/src/widgets/PlayerBar.module.css`, mobile player rules.
- [x] 8. [Accessibility] Settings, dialogs, track likes and mobile filters had small or hover-only targets. Increase key targets to 44 px, show track likes, wrap segmented controls and use 16 px form fields — `apps/web/src/shared/ui/ui.module.css`, `apps/web/src/pages/page.module.css`, `apps/web/src/entities/catalog.module.css`, mobile rules.
- [x] 9. [Visual] Oversized halo canvas and intrinsic grid widths caused horizontal scrolling. Clip only the home decoration horizontally, use shrinking shell tracks, and adapt catalogue columns and long titles — `apps/web/src/pages/home.module.css`, `.home`; `apps/web/src/widgets/AppShell.module.css`, `.shell`; `apps/web/src/pages/catalog.module.css`, mobile headings.
## Verification
- TypeScript typecheck.
- Existing unit/integration suite: 28 tests.
- Playwright: existing 4 desktop scenarios plus 4 mobile scenarios covering role navigation, user actions/create dialog, search positions, settings, populated moderation queue, path patterns, and the island player/queue.
- Browser screenshots inspected for users, create dialog, moderation, patterns, and player queue.
+1
View File
@@ -19,6 +19,7 @@
"en": "English"
},
"nav": {
"more": "More",
"main": "Navigation",
"home": "Home",
"admin": "Admin",
+1
View File
@@ -19,6 +19,7 @@
"en": "English"
},
"nav": {
"more": "Ещё",
"main": "Навигация",
"home": "Главная",
"admin": "Админка",
+6 -2
View File
@@ -3,6 +3,7 @@
// - нативные библиотеки только под arm64-v8a: неполный каталог другой ABI роняет приложение при старте
// («couldn't find DSO to load: libexpo-modules-core.so» на x86-эмуляторе);
// - есть библиотеки, без которых приложение не запустится;
// - .so сжаты (useLegacyPackaging): Android распакует их для SoLoader / ARM-трансляции;
// - PT_LOAD-сегменты каждой .so выровнены по 16 КБ: иначе на устройствах с 16 КБ страницами памяти
// (Android 15+) библиотека не загружается и приложение закрывается сразу после запуска.
// Запуск: node scripts/check-apk.mjs app-release.apk
@@ -29,11 +30,11 @@ export function zipEntries(buf) {
const commentLen = buf.readUInt16LE(p + 32);
const local = buf.readUInt32LE(p + 42);
const name = buf.toString('utf8', p + 46, p + 46 + nameLen);
out.set(name, () => {
out.set(name, Object.assign(() => {
const start = local + 30 + buf.readUInt16LE(local + 26) + buf.readUInt16LE(local + 28);
const raw = buf.subarray(start, start + size);
return method === 0 ? raw : inflateRawSync(raw);
});
}, { compressionMethod: method }));
p += 46 + nameLen + extraLen + commentLen;
}
return out;
@@ -62,6 +63,9 @@ export function checkApk(buf) {
const mine = new Set(libs.filter((n) => n.startsWith(`lib/${ABI}/`)).map((n) => n.split('/')[2]));
for (const lib of REQUIRED) if (!mine.has(lib)) problems.push(`нет lib/${ABI}/${lib}`);
for (const name of libs.filter((n) => n.startsWith(`lib/${ABI}/`))) {
if (entries.get(name).compressionMethod !== 8) {
problems.push(`${name}: библиотека не сжата, включите useLegacyPackaging для ARM-трансляции`);
}
const align = loadAlignment(entries.get(name)());
if (align < PAGE) problems.push(`${name}: сегменты выровнены по ${align} байт, нужно ${PAGE} (16 КБ страницы)`);
}
+17 -6
View File
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { deflateRawSync } from 'node:zlib';
import { checkApk, loadAlignment, REQUIRED } from './check-apk.mjs';
@@ -16,27 +17,30 @@ function elf(align) {
return b;
}
/** ZIP без сжатия из пар [имя, содержимое]. */
function zip(files) {
/** ZIP из пар [имя, содержимое]. */
function zip(files, compress = true) {
const locals = [];
const central = [];
let offset = 0;
for (const [name, data] of files) {
const packed = compress ? deflateRawSync(data) : data;
const n = Buffer.from(name);
const lh = Buffer.alloc(30);
lh.writeUInt32LE(0x04034b50, 0);
lh.writeUInt32LE(data.length, 18);
lh.writeUInt16LE(compress ? 8 : 0, 8);
lh.writeUInt32LE(packed.length, 18);
lh.writeUInt32LE(data.length, 22);
lh.writeUInt16LE(n.length, 26);
locals.push(lh, n, data);
locals.push(lh, n, packed);
const ch = Buffer.alloc(46);
ch.writeUInt32LE(0x02014b50, 0);
ch.writeUInt32LE(data.length, 20);
ch.writeUInt16LE(compress ? 8 : 0, 10);
ch.writeUInt32LE(packed.length, 20);
ch.writeUInt32LE(data.length, 24);
ch.writeUInt16LE(n.length, 28);
ch.writeUInt32LE(offset, 42);
central.push(ch, n);
offset += 30 + n.length + data.length;
offset += 30 + n.length + packed.length;
}
const cd = Buffer.concat(central);
const end = Buffer.alloc(22);
@@ -58,6 +62,13 @@ test('accepts an arm64-only APK aligned to 16 KB', () => {
assert.deepEqual(checkApk(apk).problems, []);
});
test('rejects direct APK library loading that breaks ARM translation', () => {
const apk = zip(REQUIRED.map((l) => [`lib/arm64-v8a/${l}`, elf(16384)]), false);
const { problems } = checkApk(apk);
assert.equal(problems.length, REQUIRED.length);
assert.ok(problems.every((p) => p.includes('useLegacyPackaging')));
});
test('rejects other ABIs, missing libraries and 4 KB alignment', () => {
const libs = REQUIRED.filter((l) => l !== 'libexpo-modules-core.so').map((l) => [
`lib/arm64-v8a/${l}`,