Контекст: Vue SPA завёрнут в Capacitor 8. OAuth через внешний провайдер (gnexus-auth). Должно работать одинаково на Android и десктопе. Дата: 2026-06-08. Android: 15 (API 35). Capacitor: 8.4.0.
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ Android App │────▶│ Chrome (full) │────▶│ gnexus-auth │
│ (Capacitor WV) │ │ Intent.ACTION_VIEW│ │ (OAuth page) │
└─────────────────┘ └──────────────────┘ └─────────────────┘
▲ │ │
│ ▼ ▼
│ ┌──────────────────┐ ┌─────────────────┐
└──────────────│ /auth/callback │────▶│ /auth/mobile- │
intent:// │ (server OAuth │ │ bridge │
deep link │ callback) │ │ (bridge page) │
└──────────────────┘ └─────────────────┘
Browser.open() из @capacitor/browser) — не поддерживает intent:// URL. Браузер открывает CCT, авторизуется, но не может вернуть управление в приложение. Нельзя использовать для deep link return.Intent.ACTION_VIEW) — открывает полный Chrome с адресной строкой. Chrome нативно поддерживает intent:// ссылки. После авторизации bridge page делает window.location.href = "intent://auth/callback?token=...#Intent;scheme=shserv;package=com.gnexus.shserv;end" — и управление возвращается в приложение.BridgeWebViewClient автоматически перехватывает навигацию на внешние хосты и открывает их через Intent.ACTION_VIEW. Поэтому в Vue-коде достаточно window.location.href = buildServerUrl('/auth/login?return_to=/auth/mobile-bridge') — без Browser.open().Нажимаешь "Sign In", открывается браузер, авторизуешься, но приложение не получает токен. Пользователь остаётся в браузере.
Browser.open({ url: '...' }) открывает Chrome Custom Tabs (или DuckDuckGo CCT). CCT — это sandboxed WebView, который не умеет обрабатывать intent:// ссылки. Bridge page пытается window.location.href = "intent://...", но CCT не передаёт это в систему.
// ❌ НЕЛЬЗЯ — открывает CCT, intent:// не сработает
import { Browser } from '@capacitor/browser';
Browser.open({ url: authUrl });
// ✅ ПРАВИЛЬНО — Capacitor BridgeWebViewClient сам перехватывает
// внешние хосты и открывает через Intent.ACTION_VIEW (full Chrome)
window.location.href = buildServerUrl(
`/auth/login?return_to=${encodeURIComponent('/auth/mobile-bridge')}`
);
На сервере AuthController::callback() вызывал $service->handleCallback($code, $state), который удаляет OAuth state из PHP-сессии (stateStore->forget($state)). После этого код пытался прочитать $_SESSION['gauth_state'][$state]['context'] — но state уже стёрт. returnTo всегда падал в '/'.
public function callback()
{
// ...
// ✅ Читаем returnTo ПОКА state живой в сессии
$context = $_SESSION['gauth_state'][$state]['context'] ?? [];
$service = new AuthService();
$user = $service->handleCallback($code, $state); // state тут стирается
// ...
$returnTo = $context['return_to'] ?? '/';
return $this->utils()->redirect($returnTo);
}
После добавления Capacitor-зависимостей десктопный клиент показывает /#/mobile-setup и требует указать адрес сервера. Либо авторизация цикличит.
isNativeApp() проверял window.Capacitor !== undefined// ❌ НЕЛЬЗЯ — window.Capacitor injects и в браузер (dev serve, live reload)
export function isNativeApp() {
return typeof window !== "undefined" && window.Capacitor !== undefined;
}
Capacitor runtime может присутствовать и в браузере (например, при npm run dev через Vite proxy). Десктоп думает что он нативное приложение:
/#/mobile-setup (router guard hasServerUrl())/auth/mobile-bridge с intent:// (который в браузере не работает)initAccessToken() не вызывается (был под if (isNativeApp())), токен не загружаетсяCapacitor.isNativePlatform()import { Capacitor } from "@capacitor/core";
export function isNativeApp() {
return Capacitor.isNativePlatform(); // true только на реальных iOS/Android
}
Также: всегда вызывать initAccessToken() в bootstrap(), не под if (isNativeApp()). Эта функция сама внутри различает web (localStorage) и native (Preferences).
Верхняя часть приложения (заголовок, логотип) залезает под статус бар. Статус бар прозрачный.
Android 15 принудительно включает edge-to-edge для всех приложений. Контент рисуется за статус баром и navigation bar. Это нельзя отключить стандартными windowTranslucentStatus=false.
Capacitor 8 добавляет SystemBars plugin, который:
OnApplyWindowInsetsListener на CoordinatorLayoutsystemBars insets = 0, блокируя fitsSystemWindowsfitsSystemWindows="true"Создать res/layout/capacitor_bridge_layout_main.xml — override'ит Capacitor'овский layout:
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true">
<com.getcapacitor.CapacitorWebView
android:id="@+id/webview"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
public class MainActivity extends BridgeActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
setTheme(R.style.AppTheme_NoActionBar);
super.onCreate(savedInstanceState);
// Prevent edge-to-edge
Window window = getWindow();
WindowCompat.setDecorFitsSystemWindows(window, true);
window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS);
window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS);
// ✅ Сбрасываем OnApplyWindowInsetsListener от Capacitor SystemBars plugin
View webView = findViewById(com.getcapacitor.android.R.id.webview);
if (webView != null) {
ViewGroup parent = (ViewGroup) webView.getParent();
if (parent != null) {
ViewCompat.setOnApplyWindowInsetsListener(parent, null);
parent.setFitsSystemWindows(true);
}
}
}
}
styles.xml — оба стиля:
<item name="android:windowOptOutEdgeToEdgeEnforcement">true</item>
capacitor.config.json:
"android": {
"adjustMarginsForEdgeToEdge": "force"
}
Иконка на splash screen размытая (низкое разрешение) или расплющенная (неправильные пропорции).
Capacitor по умолчанию генерирует splash PNG ~200×200px для drawable/. На xxxhdpi (1440×2560) это растягивается в ~7× — получается мыло.
mdpi: 360×640 / 640×360 (logo 120px) hdpi: 540×960 / 960×540 (logo 180px) xhdpi: 720×1280 / 1280×720 (logo 240px) xxhdpi: 1080×1920 / 1920×1080 (logo 360px) xxxhdpi: 1440×2560 / 2560×1440 (logo 480px)
Генерировать из SVG через ImageMagick, центрировать логотип gravity=center.
Android 12+ (API 31+) использует SplashScreen API. Иконка должна быть 288dp canvas с 192dp content area. Если иконка меньше или неправильного формата — система растягивает/сплющивает.
values-v31/styles.xml:
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
<item name="android:windowSplashScreenBackground">@color/bg_dark</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/ic_splash</item>
<item name="android:windowSplashScreenIconBackgroundColor">@color/bg_dark</item>
<item name="postSplashScreenTheme">@style/AppTheme.NoActionBar</item>
</style>
drawable-*dpi-v31/ic_splash.png:
mdpi: 288×288 (logo 192px) hdpi: 432×432 (logo 288px) xhdpi: 576×576 (logo 384px) xxhdpi: 864×864 (logo 576px) xxxhdpi: 1152×1152 (logo 768px)
Генерация:
magick -size ${canvas}x${canvas} xc:#0f172a \
logo.svg -resize ${logo}x${logo} \
-gravity center -compose over -composite \
drawable-${d}-v31/ic_splash.png
Не использовать layer-list с маленьким 48dp — это неправильный размер для SplashScreen API.
Зачем: не пересобирать APK при каждом изменении UI. SPA грузится с сервера — обновляешь
dist/на сервере, приложение подхватывает автоматически.
┌─────────────────┐ ┌──────────────────┐
│ Android App │────▶│ Server SPA │
│ (Capacitor WV) │ │ (dist/index.html│
│ тонкий клиент │ │ + JS + CSS) │
└─────────────────┘ └──────────────────┘
│ │
│ ▼
│ ┌──────────────────┐
└────────────│ PHP API │
XHR │ /api/v1/* │
└──────────────────┘
Первый запуск (нет server_url в Preferences):
index.html из APK (assets/public)/mobile-setup@capacitor/preferences → SharedPreferences("CapacitorStorage")App.exitApp() — приложение закрываетсяВторой и последующие запуски:
MainActivity.onCreate() перед super.onCreate() читает server_url из PreferencesCapConfig через Builder с setServerUrl(url)super.onCreate(savedInstanceState) видит этот config и WebView грузит SPA с сервераpublic class MainActivity extends BridgeActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
setTheme(R.style.AppTheme_NoActionBar);
// ⚠️ ДО super.onCreate() — иначе BridgeActivity загрузит bundled assets
SharedPreferences prefs = getSharedPreferences("CapacitorStorage", MODE_PRIVATE);
String serverUrl = prefs.getString("server_url", null);
if (serverUrl != null && !serverUrl.isEmpty()) {
this.config = new CapConfig.Builder(this)
.setServerUrl(serverUrl)
.create();
}
super.onCreate(savedInstanceState);
// ... rest of onCreate (status bar, edge-to-edge, etc.)
}
}
Критически важно: this.config присваивается перед super.onCreate(). BridgeActivity.onCreate() вызывает load() → bridgeBuilder.setConfig(config) → если config уже есть, CapConfig читает server.url и WebView открывает его вместо file:///android_asset/public/index.html.
<script setup>
import { App } from "@capacitor/app";
// ...
async function handleSave() {
// ... validate URL, test connectivity ...
await setServerUrl(url); // сохраняет в @capacitor/preferences
alert("Server address saved. Please restart the app to continue.");
App.exitApp(); // пользователь перезапускает приложение вручную
}
</script>
capacitor.config.json?В capacitor.config.json можно статически прописать "server": { "url": "https://..." }, но тогда всегда будет грузиться с сервера, даже при первом запуске (когда URL ещё неизвестен). Динамический CapConfig.Builder в MainActivity позволяет:
/mobile-setup| Что | Зачем |
|---|---|
Bundled dist/ в assets/public |
Fallback для первого запуска и офлайн-режима |
| Capacitor plugins (App, Preferences, StatusBar) | Нативная функциональность |
| Splash screen, themes, layouts | Нативная обёртка |
| Deep link intent-filter | OAuth callback |
usesCleartextTraffic="true" в AndroidManifest.xml — нужен для HTTP (локальная сеть)shserv://auth/callback — обрабатывается на уровне Android (intent-filter), не зависит от serverUrlonReceivedError в WebViewClient для graceful fallback.capacitor.config.json с "appId": "com.company.app" — уникальныйandroidScheme не конфликтует с deep link scheme (shserv / https)CapacitorHttp.enabled: true если нужен native HTTPadjustMarginsForEdgeToEdge: "force" для Android 15+AndroidManifest.xml intent-filter для scheme://host/pathintent:// URLappUrlOpen listener + getLaunchUrl() для cold-startwindow.location.reload() после получения токена (чтобы bootstrap перезапустился)isNativeApp() через Capacitor.isNativePlatform(), НЕ window.CapacitorinitAccessToken() и initServerUrl() вызывать всегда в bootstrapstorage.js) — единая точка для Preferences/localStoragenavigation.js) — единая точка для OAuth redirectMainActivity.onCreate() читает server_url из Preferences перед super.onCreate()CapConfig.Builder(this).setServerUrl(url) если URL сохранён/mobile-setup страница — сохраняет URL и вызывает App.exitApp()dist/ в APK как fallback для первого запускаMainActivity.java с setTheme() ПЕРЕД super.onCreate()capacitor_bridge_layout_main.xml с fitsSystemWindows="true"OnApplyWindowInsetsListener от SystemBars plugin в onCreate()windowOptOutEdgeToEdgeEnforcement=true в обоих theme стиляхdrawable-land-*dpi/ и drawable-port-*dpi/drawable-*dpi-v31/ic_splash.png 288dp canvascolors.xml с bg_dark, toolbar_bg, accent, text_primaryreturnTo читать из сессии ДО handleCallback() — state стирается/auth/mobile-bridge с intent:// redirectsanitizeReturnTo() разрешает /auth/mobile-bridge и same-origin URLs| Файл | Зачем |
|---|---|
server/SHServ/Controllers/AuthController.php |
OAuth callback + mobileBridge bridge page |
webclient/src/app/main.js |
Bootstrap, deep link handling, initAccessToken() |
webclient/src/api/server-config.js |
isNativeApp(), buildServerUrl(), storage |
webclient/src/api/storage.js |
Единый адаптер Preferences/localStorage |
webclient/src/api/navigation.js |
redirectToOAuth(), getOAuthReturnTo() |
webclient/src/features/auth/pages/MobileSetupPage.vue |
Страница ввода адреса сервера (только native) |
webclient/src/api/storage.js |
Единый адаптер Preferences/localStorage |
webclient/capacitor.config.json |
Capacitor config, plugins, android scheme |
android/app/src/main/java/.../MainActivity.java |
Theme override, fitsSystemWindows, edge-to-edge opt-out |
android/app/src/main/res/layout/capacitor_bridge_layout_main.xml |
Override с fitsSystemWindows="true" |
android/app/src/main/res/values/styles.xml |
Theme + windowOptOutEdgeToEdgeEnforcement |
android/app/src/main/res/values-v31/styles.xml |
Android 12+ SplashScreen API |
android/app/src/main/AndroidManifest.xml |
Deep link intent-filter |
Написано после реального production-дебага. Все проблемы воспроизводились на Android 15 (API 35), Motorola, Capacitor 8.4.0.