JavaScript bridge API
Отчёты о сбоях и целостность устройства
Ошибки, которые записывает страница, последний нативный сбой и то, похож ли телефон на взломанный или изменённый, с необязательным токеном Play Integrity для вашего сервера.
AppMint.integrity помощник#
AppMint.integrity.check() / AppMint.integrity.token(nonce, cloudProjectNumber?)
Пример
Версии getDeviceIntegrity и requestIntegrityToken на промисах, доступные до первого скрипта вашей страницы, когда в сборке включены Проверки целостности устройства (шаг «Интеграция», шаг 3).
Возвращает: check() возвращает объект эвристик ({rooted, emulator, debuggerAttached, signals, installer, installedFromPlay, signatureSha256, method: 'heuristic'}); token(nonce) возвращает строку токена Play Integrity или отклоняется с Error, сообщение которого — имя ошибки (not_enabled, nonce_too_short, network_error, …), а code — код ошибки Play. Эвристики — лишь подсказки; вердиктом является только расшифровка токена вашим сервером.
async function guard() {
if (!(window.AppMint && AppMint.integrity)) return; // browser, or option off
const local = await AppMint.integrity.check();
if (local.rooted) console.warn('rooted signals:', local.signals);
const { nonce } = await fetch('/api/integrity/nonce').then(r => r.json());
try {
const token = await AppMint.integrity.token(nonce); // project number from the build
await fetch('/api/integrity/verify', { method: 'POST', body: token });
} catch (e) {
console.warn('Play Integrity unavailable:', e.message, e.code);
}
}Предупредите, если приложение — перепакованная копия:
if (window.AppMint && AppMint.integrity) {
const d = await AppMint.integrity.check();
if (!d.installedFromPlay) console.log('installed by', d.installer || 'side-loaded');
if (d.signatureSha256 && d.signatureSha256 !== MY_CERT_SHA256) reportTamper(d.signatureSha256);
}Обработайте сбой токена по имени:
async function getToken(nonce) {
if (!(window.AppMint && AppMint.integrity)) return null;
try {
return await AppMint.integrity.token(nonce);
} catch (e) {
if (e.message === 'not_enabled') console.warn('Device integrity checks are off in this build');
else if (e.message === 'nonce_too_short') console.warn('the server nonce must be longer');
else console.warn('Play Integrity error', e.message, e.code);
return null;
}
}getDeviceIntegrity мост#
window.WebToApk.getDeviceIntegrity(): String
Локальные ЭВРИСТИКИ root / эмулятора / отладчика (см. DeviceIntegrity) в виде JSON: `{rooted, emulator, debuggerAttached, signals:[…], installer, installedFromPlay, signatureSha256, method:"heuristic", sdk}`. Чистый результат означает «признаков не найдено», а не доказательство — для надёжного вердикта отправьте токен Play Integrity на ваш сервер (requestIntegrityToken). `{"error":"not_enabled"}`, если в сборке не включена проверка целостности устройства. Блокирует на несколько миллисекунд (читает файлы): вызывайте один раз.
Пример
Локальные ЭВРИСТИКИ root, эмулятора и отладчика в виде строки JSON. Чистый результат означает «признаков не найдено», а не доказательство: настойчивый пользователь с модулем, скрывающим root, пройдёт. Используйте это, чтобы предупредить или добавить проверку; для надёжного вердикта отправьте токен Play Integrity на ваш сервер (requestIntegrityToken).
Возвращает: '{"rooted":false,"emulator":false,"debuggerAttached":false,"signals":[],"installer":"com.android.vending","installedFromPlay":true,"signatureSha256":"AB:CD:…","method":"heuristic","sdk":34}' или '{"error":"not_enabled"}'. Сигналы называют то, что найдено: test-keys, su-binary:/system/xbin/su, root-path:/data/adb/magisk, root-app:com.topjohnwu.magisk, system-writable, ro.debuggable=1, ro.secure=0, selinux-permissive, bootloader-unlocked, emulator:*, debugger-attached. Требуется: включите Проверки целостности устройства на шаге «Интеграция» (шаг 3). Занимает несколько миллисекунд (читает файлы): вызывайте один раз, а не в цикле.
function deviceCheck() {
if (!(window.WebToApk && window.WebToApk.getDeviceIntegrity)) return null;
const r = JSON.parse(WebToApk.getDeviceIntegrity());
if (r.error) return null; // 'not_enabled'
return r;
}
const d = deviceCheck();
if (d && d.rooted) {
showNotice('This phone looks rooted (' + d.signals.join(', ') + '). Some features are turned off.');
}
if (d && !d.installedFromPlay) console.log('installed by', d.installer || 'unknown (side-loaded)');Проверьте собственную подпись — перепакованная копия вашего приложения подписана другим ключом:
const MY_CERT = 'AB:CD:EF:…'; // Play Console → App integrity → App signing key SHA-256
const d = deviceCheck();
if (d && d.signatureSha256 && d.signatureSha256 !== MY_CERT) reportTamper();Запросите у сервера надёжный вердикт с помощью токена Play Integrity (ответ приходит как событие).
window.addEventListener('appmint:integrity', function (e) {
if (e.detail.error) { showNotice('Check failed: ' + e.detail.error); return; }
fetch('https://api.example.com/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: e.detail.token })
});
});
if (window.WebToApk && WebToApk.requestIntegrityToken) {
const nonce = crypto.randomUUID().replace(/-/g, '') + '0000'; // your server issues and checks this
WebToApk.requestIntegrityToken(nonce, '123456789012'); // your Google Cloud project number
}getLastCrash мост#
window.WebToApk.getLastCrash(): String
Последний сбой, записанный этой установкой (закрытие приложения из-за неперехваченной ошибки или сбой рендерера страницы), в виде JSON `{kind, message, stack, url, at, count, appVersion, user, fp}` или "", если сбоев не было (или отчёты о сбоях выключены). Сохраняется после отправки, чтобы страница могла сказать «извините, в прошлый раз приложение закрылось».
Пример
Последний сбой, записанный этой установкой — закрытие приложения из-за неперехваченной ошибки (kind: "native") или сбой рендерера страницы (kind: "renderer") — в виде строки JSON или '', если сбоев не было.
Возвращает: '{"kind","message","stack","url","at","count","appVersion","user","fp"}' или ''. Также '', если в сборке не включены отчёты о сбоях. Требуется: включённые Отчёты о сбоях на шаге «Интеграция» (шаг 3).
Извинитесь один раз после сбоя:
(function () {
if (!(window.WebToApk && window.WebToApk.getLastCrash)) return;
const raw = WebToApk.getLastCrash();
if (!raw) return;
const crash = JSON.parse(raw);
if (localStorage.getItem('seenCrash') === String(crash.at)) return; // shown already
localStorage.setItem('seenCrash', String(crash.at));
showBanner('Sorry, the app closed unexpectedly last time. We have been told about it.');
})();Отправьте сохранённый сбой на ваш собственный сервер один раз, вместе с версией приложения, на которой он произошёл.
(function () {
if (!(window.WebToApk && WebToApk.getLastCrash)) return;
const raw = WebToApk.getLastCrash();
if (!raw) return; // no crash, or Crash reports are off
const c = JSON.parse(raw);
if (localStorage.getItem('sentCrash') === String(c.at)) return;
fetch('https://api.example.com/crashes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ kind: c.kind, message: c.message, version: c.appVersion, count: c.count })
}).then(function () { localStorage.setItem('sentCrash', String(c.at)); });
})();Разветвляйте логику по типу, чтобы показать подходящее сообщение.
const raw = window.WebToApk && WebToApk.getLastCrash && WebToApk.getLastCrash();
if (raw) {
const c = JSON.parse(raw);
showBanner(c.kind === 'renderer'
? 'The page ran out of memory last time. Try closing other apps.'
: 'The app closed unexpectedly last time.');
}Примечания: он остаётся после отправки отчёта, поэтому сравнивайте at с тем, что вы сохранили, чтобы показать сообщение один раз. Ошибки страницы и вызовы logError — не сбои и здесь никогда не появляются.
logError мост#
window.WebToApk.logError(message: String, stack: String): Boolean
Отчёты о сбоях: записывает ошибку, которую ваша страница ПЕРЕХВАТИЛА (сбойный вызов платежа, ошибка разбора), чтобы она появилась на экране AppMint «Отчёты о сбоях» рядом со сбоями, сгруппированная и подсчитанная. [stack]: `err.stack` или "". Возвращает false, если в сборке не включены отчёты о сбоях, если оба аргумента пусты или если та же ошибка уже много раз записывалась за этот сеанс. Никогда не помещайте в сообщение персональные данные (почту, телефон, токены).
Пример
Записывает ошибку, которую ваша страница ПЕРЕХВАТИЛА (сбойный вызов платежа, ошибка разбора JSON), чтобы она появилась на экране AppMint Отчёты о сбоях (Обновить приложение → ваше приложение → Отчёты о сбоях), сгруппированная со сбоями и подсчитанная. Неперехваченные ошибки и необработанные отклонения промисов записываются автоматически; это для тех, что вы обработали.
Возвращает: true, когда записано; false, если в сборке не включены отчёты о сбоях, если оба аргумента пусты или если та же ошибка уже много раз записывалась за этот сеанс. Отчёты отправляются при следующем открытии приложения. Требуется: включите Отчёты о сбоях на шаге «Интеграция» (шаг 3) при сборке. Никогда не помещайте в сообщение персональные данные (адрес электронной почты, номер телефона, токены).
Запишите обработанную ошибку:
function logError(err, context) {
if (!(window.WebToApk && window.WebToApk.logError)) return false; // browser, or an older build
var msg = (context ? context + ': ' : '') + (err && err.message ? err.message : String(err));
return WebToApk.logError(msg, (err && err.stack) || '');
}
async function checkout(cart) {
try {
await payments.charge(cart.total);
} catch (err) {
logError(err, 'checkout'); // shows up as "Logged by your page"
showToast('Payment failed, please try again');
}
}Сообщайте о неудачных вызовах API без строки запроса из адреса (в ней могут быть токены):
async function api(path) {
const res = await fetch('/api' + path);
if (!res.ok && window.WebToApk && WebToApk.logError) {
WebToApk.logError('API ' + res.status + ' ' + path.split('?')[0], '');
}
return res;
}Запишите перехваченную ошибку с идентификатором пользователя, чтобы позже можно было найти проблемы конкретного пользователя.
if (window.WebToApk && WebToApk.setCrashUserId) WebToApk.setCrashUserId('user_' + currentUser.id);
try {
JSON.parse(savedState);
} catch (err) {
if (window.WebToApk && WebToApk.logError) {
const logged = WebToApk.logError('state parse: ' + err.message, err.stack || '');
if (!logged) console.warn('not recorded (Crash reports off, or repeated too often)');
}
}Примечания: отчёты с одной и той же причиной группируются, даже если числа в сообщении различаются (item 42 и item 7). Адрес страницы сохраняется без параметров запроса и фрагмента.
requestIntegrityToken мост#
window.WebToApk.requestIntegrityToken(nonce: String, cloudProjectNumber: String): String
Запрашивает у Google Play токен Play Integrity (классический запрос). [nonce]: Base64, безопасный для URL, 16–500 символов, созданный ВАШИМ сервером для этого единственного запроса. [cloudProjectNumber]: номер вашего проекта Google Cloud или "", чтобы использовать указанный в сборке (приложение, установленное из Play, может оставить оба пустыми). Сразу возвращает идентификатор запроса ("", если в сборке не включена проверка целостности устройства); ответ приходит как `appmint:integrity` / `window.onAppMintIntegrity({requestId, token})` или `{requestId, error, code}`. Отправьте токен на ваш сервер, который расшифрует его через Google (playintegrity.googleapis.com … :decodeIntegrityToken) и проверит nonce.
Пример
Запрашивает у Google Play токен Play Integrity (классический запрос). На телефоне токен непрозрачен: ваш СЕРВЕР отправляет его в Google, читает вердикт (подлинное приложение? подлинное устройство? лицензировано?) и проверяет, что nonce — тот, который он выдавал. Именно эта проверка на сервере делает ответ надёжным.
Возвращает: идентификатор запроса сразу ('', если в сборке не включена проверка целостности устройства). Ответ приходит как событие appmint:integrity (и window.onAppMintIntegrity) с {requestId, token} или {requestId, error, code} — ошибки вроде nonce_too_short, nonce_is_not_base64, network_error, play_store_not_found, cloud_project_number_is_invalid, too_many_requests. Требуется: включённые Проверки целостности устройства на шаге «Интеграция» (шаг 3), включённый для приложения Play Integrity API (Play Console → Целостность приложения) и — для приложения, НЕ установленного из Google Play, — номер вашего проекта Google Cloud в мастере или вторым аргументом ('' использует номер из сборки).
async function integrityToken() {
// 1. Your server makes a one-time nonce: URL-safe Base64, 16-500 characters.
const { nonce } = await fetch('/api/integrity/nonce').then(r => r.json());
return new Promise((resolve, reject) => {
const id = WebToApk.requestIntegrityToken(nonce, '');
if (!id) return reject(new Error('not_enabled'));
window.addEventListener('appmint:integrity', function on(e) {
if (e.detail.requestId !== id) return;
window.removeEventListener('appmint:integrity', on);
e.detail.token ? resolve(e.detail.token) : reject(new Error(e.detail.error));
});
});
}
// 2. Your server decodes it with Google and decides.
const token = await integrityToken();
const verdict = await fetch('/api/integrity/verify', { method: 'POST', body: token }).then(r => r.json());Шаг на сервере — вызовите POST https://playintegrity.googleapis.com/v1/<your.package>:decodeIntegrityToken с {"integrity_token": token}, используя служебный аккаунт связанного проекта Cloud, затем проверьте:
const p = decoded.tokenPayloadExternal;
const ok = p.requestDetails.nonce === issuedNonce &&
p.appIntegrity.appRecognitionVerdict === 'PLAY_RECOGNIZED' &&
p.deviceIntegrity.deviceRecognitionVerdict.includes('MEETS_DEVICE_INTEGRITY');Форма с обратным вызовом для кода, который не может слушать события:
window.onAppMintIntegrity = function (d) {
if (d.error) console.warn('integrity failed', d.error, d.code);
else sendToServer(d.token);
};
WebToApk.requestIntegrityToken(nonceFromServer, '123456789012');setCrashUserId мост#
window.WebToApk.setCrashUserId(id: String): Boolean
Отчёты о сбоях: ВАШ идентификатор вошедшего пользователя (буквы, цифры и _ @ . : + -, до 128), прикрепляется к отчётам с этого момента, чтобы вы могли считать затронутых пользователей и находить сбои конкретного пользователя. "" сбрасывает его. Никогда не адрес электронной почты и не номер телефона. Возвращает false для идентификатора с другими символами или когда отчёты о сбоях выключены.
Пример
Прикрепляет ВАШ идентификатор вошедшего пользователя к отчётам о сбоях с этого момента, чтобы экран «Отчёты о сбоях» мог считать, скольких пользователей затрагивает проблема. Вызывайте после входа; при выходе вызывайте с ''.
Возвращает: true при сохранении; false для идентификатора с символами, отличными от букв, цифр и _ @ . : + - (или длиннее 128), или если в сборке не включены отчёты о сбоях. Требуется: включённые Отчёты о сбоях на шаге «Интеграция» (шаг 3). Используйте идентификатор из вашей базы данных, никогда не адрес электронной почты и не номер телефона.
function crashUser(id) {
if (!(window.WebToApk && window.WebToApk.setCrashUserId)) return false;
return WebToApk.setCrashUserId(id);
}
async function signIn(email, password) {
const user = await myBackend.signIn(email, password);
crashUser(user.id); // e.g. 'u_42'
}
function signOut() {
crashUser(''); // later reports carry no user
}Отклонённый идентификатор или сборка без отчётов о сбоях возвращает false:
function setCrashUser(id) {
if (!(window.WebToApk && WebToApk.setCrashUserId)) return;
if (!WebToApk.setCrashUserId(id)) {
console.warn('Crash user id not saved: bad characters, over 128 long, or Crash reports are off');
}
}
setCrashUser('u_42'); // your database id, never an e-mail addressСообщите пользователю о последнем сбое после перезапуска:
if (window.WebToApk && WebToApk.getLastCrash) {
const raw = WebToApk.getLastCrash(); // '' when there has been none
if (raw) {
const c = JSON.parse(raw);
showMessage('Sorry, the app closed last time (' + c.kind + ').');
}
}Примечания: идентификатор остаётся на телефоне, пока вы его не смените, и отправляется только внутри отчётов о сбоях.