ci / api / api (push) Successful in 40s
ci / db / db (push) Successful in 1m3s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Failing after 1m48s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 46s
ci / ts / ts (packages + web) (push) Failing after 4m57s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 5m7s
Quality upgrades, batch covers, staging preview, and resume after reload are in the queue. Loudness and crossfade work on web and Android, and the documented checks now have suites. Co-authored-by: Cursor <cursoragent@cursor.com>
188 lines
6.9 KiB
TypeScript
188 lines
6.9 KiB
TypeScript
import { unwrap, type AuthSession } from '@pipu/core';
|
|
import type { components } from '@pipu/api-client';
|
|
import Constants from 'expo-constants';
|
|
import * as FileSystem from 'expo-file-system';
|
|
import * as IntentLauncher from 'expo-intent-launcher';
|
|
import * as SecureStore from 'expo-secure-store';
|
|
import { sha256 } from 'js-sha256';
|
|
|
|
import { releaseNeed } from './releaseNeed';
|
|
|
|
export type AndroidRelease = components['schemas']['AndroidRelease'];
|
|
|
|
/** Состояние самообновления (docs/mobile.md#самообновление). */
|
|
export type UpdateState =
|
|
| { status: 'idle' }
|
|
| { status: 'checking' }
|
|
| { status: 'latest' }
|
|
| {
|
|
status: 'available';
|
|
release: AndroidRelease;
|
|
required: boolean;
|
|
dismissed: boolean;
|
|
}
|
|
| {
|
|
status: 'downloading';
|
|
release: AndroidRelease;
|
|
required: boolean;
|
|
progress: number;
|
|
}
|
|
| { status: 'verifying'; release: AndroidRelease; required: boolean }
|
|
| {
|
|
status: 'ready';
|
|
release: AndroidRelease;
|
|
required: boolean;
|
|
file: string;
|
|
}
|
|
| {
|
|
status: 'error';
|
|
release: AndroidRelease;
|
|
required: boolean;
|
|
error: 'bad_checksum' | 'failed';
|
|
};
|
|
|
|
const CHECK_EVERY_MS = 6 * 60 * 60 * 1000;
|
|
const CHECKED_KEY = 'pipu.update.checked';
|
|
const HASH_CHUNK = 1 << 20;
|
|
const APK_MIME = 'application/vnd.android.package-archive';
|
|
|
|
export function installedVersionCode(): number {
|
|
return Number((Constants.expoConfig?.extra as { versionCode?: number } | undefined)?.versionCode ?? 0);
|
|
}
|
|
|
|
/** SHA-256 файла кусками по 1 МБ: целиком APK в память JS не читаем. */
|
|
async function fileSha256(uri: string, size: number): Promise<string> {
|
|
const hash = sha256.create();
|
|
for (let pos = 0; pos < size; pos += HASH_CHUNK) {
|
|
const b64 = await FileSystem.readAsStringAsync(uri, {
|
|
encoding: FileSystem.EncodingType.Base64,
|
|
position: pos,
|
|
length: Math.min(HASH_CHUNK, size - pos),
|
|
});
|
|
const bin = atob(b64);
|
|
const bytes = new Uint8Array(bin.length);
|
|
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
|
hash.update(bytes);
|
|
}
|
|
return hash.hex();
|
|
}
|
|
|
|
class Updater {
|
|
private current: UpdateState = { status: 'idle' };
|
|
private readonly listeners = new Set<() => void>();
|
|
private download: FileSystem.DownloadResumable | null = null;
|
|
|
|
get state(): UpdateState {
|
|
return this.current;
|
|
}
|
|
|
|
subscribe = (fn: () => void) => {
|
|
this.listeners.add(fn);
|
|
return () => this.listeners.delete(fn);
|
|
};
|
|
|
|
private set(s: UpdateState) {
|
|
this.current = s;
|
|
for (const fn of this.listeners) fn();
|
|
}
|
|
|
|
/** Проверка: автоматически — не чаще раза в 6 часов, вручную (профиль) — всегда. */
|
|
async check(session: AuthSession, manual = false): Promise<void> {
|
|
if (['checking', 'downloading', 'verifying'].includes(this.current.status)) return;
|
|
if (!manual) {
|
|
const last = Number((await SecureStore.getItemAsync(CHECKED_KEY)) ?? 0);
|
|
if (Date.now() - last < CHECK_EVERY_MS) return;
|
|
}
|
|
if (manual) this.set({ status: 'checking' });
|
|
try {
|
|
const release = unwrap(await session.client.GET('/api/v1/app/android/latest'));
|
|
await SecureStore.setItemAsync(CHECKED_KEY, String(Date.now()));
|
|
const installed = installedVersionCode();
|
|
const need = releaseNeed(installed, release.version_code, release.min_supported);
|
|
if (need !== 'current') {
|
|
this.set({
|
|
status: 'available',
|
|
release,
|
|
required: need === 'required',
|
|
dismissed: false,
|
|
});
|
|
} else {
|
|
this.set(manual ? { status: 'latest' } : { status: 'idle' });
|
|
}
|
|
} catch {
|
|
// Нет релиза или сервер без Gitea — молча; при ручной проверке показываем «последняя версия».
|
|
this.set(manual ? { status: 'latest' } : { status: 'idle' });
|
|
}
|
|
}
|
|
|
|
dismiss() {
|
|
const s = this.current;
|
|
if (s.status === 'available' && !s.required) this.set({ ...s, dismissed: true });
|
|
else if (s.status === 'latest') this.set({ status: 'idle' });
|
|
}
|
|
|
|
/** Скачивает APK с сервера (с Bearer), сверяет SHA-256 и запускает установщик. */
|
|
async downloadAndInstall(session: AuthSession, server: string): Promise<void> {
|
|
const s = this.current;
|
|
if (s.status !== 'available' && s.status !== 'error' && s.status !== 'ready') return;
|
|
const { release, required } = s;
|
|
if (s.status === 'ready') return this.install(s.file);
|
|
|
|
// Любой запрос через client обновит access-токен, если он истёк.
|
|
await session.client.GET('/api/v1/app/android/latest').catch(() => undefined);
|
|
const file = `${FileSystem.cacheDirectory}pipu-music-${release.version}.apk`;
|
|
this.set({ status: 'downloading', release, required, progress: 0 });
|
|
try {
|
|
await FileSystem.deleteAsync(file, { idempotent: true });
|
|
this.download = FileSystem.createDownloadResumable(
|
|
server + release.download_url,
|
|
file,
|
|
{ headers: { Authorization: `Bearer ${session.accessToken ?? ''}` } },
|
|
(p) => {
|
|
const total = p.totalBytesExpectedToWrite > 0 ? p.totalBytesExpectedToWrite : release.size;
|
|
this.set({
|
|
status: 'downloading',
|
|
release,
|
|
required,
|
|
progress: total ? p.totalBytesWritten / total : 0,
|
|
});
|
|
},
|
|
);
|
|
const res = await this.download.downloadAsync();
|
|
this.download = null;
|
|
if (!res || res.status !== 200) throw new Error(`status ${res?.status}`);
|
|
|
|
this.set({ status: 'verifying', release, required });
|
|
const info = await FileSystem.getInfoAsync(file);
|
|
const size = info.exists ? info.size : 0;
|
|
if ((await fileSha256(file, size)) !== release.sha256.toLowerCase()) {
|
|
await FileSystem.deleteAsync(file, { idempotent: true });
|
|
this.set({ status: 'error', release, required, error: 'bad_checksum' });
|
|
return;
|
|
}
|
|
this.set({ status: 'ready', release, required, file });
|
|
await this.install(file);
|
|
} catch {
|
|
this.download = null;
|
|
this.set({ status: 'error', release, required, error: 'failed' });
|
|
}
|
|
}
|
|
|
|
private async install(file: string) {
|
|
const uri = await FileSystem.getContentUriAsync(file);
|
|
await IntentLauncher.startActivityAsync('android.intent.action.VIEW', {
|
|
data: uri,
|
|
type: APK_MIME,
|
|
flags: 1, // FLAG_GRANT_READ_URI_PERMISSION
|
|
});
|
|
}
|
|
|
|
/** Экран «Установка неизвестных приложений» для нашего пакета. */
|
|
async openInstallSettings() {
|
|
const pkg = Constants.expoConfig?.android?.package ?? 'app.pipu.music';
|
|
await IntentLauncher.startActivityAsync('android.settings.MANAGE_UNKNOWN_APP_SOURCES', { data: `package:${pkg}` });
|
|
}
|
|
}
|
|
|
|
export const updater = new Updater();
|