372 lines
12 KiB
Dart
372 lines
12 KiB
Dart
import 'dart:collection';
|
|
|
|
import 'package:flutter/foundation.dart' show kDebugMode;
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
|
import 'package:material_symbols_icons/symbols.dart';
|
|
|
|
import '../../../backend/modules/webapp.dart';
|
|
import '../../../main.dart' show webAppModule, digitalIdModule;
|
|
import '../../widgets/connection_status.dart';
|
|
import '../../widgets/webview_permission_prompt.dart';
|
|
|
|
Future<void> resetDigitalIdWebData() async {
|
|
await CookieManager.instance().deleteAllCookies();
|
|
try {
|
|
await WebStorageManager.instance().deleteAllData();
|
|
} catch (_) {}
|
|
}
|
|
|
|
Future<void> resetDigitalIdSession() async {
|
|
digitalIdModule.reset();
|
|
try {
|
|
await resetDigitalIdWebData();
|
|
} catch (_) {}
|
|
}
|
|
|
|
const String _kBridge = r'''
|
|
(function(){
|
|
var sawOpenLink = false;
|
|
var DBG = !!window.__KOMET_DID_DEBUG;
|
|
function log(m){ if (!DBG) return; try { console.log('[BRIDGE] ' + m); } catch(e){} }
|
|
if (DBG) {
|
|
try {
|
|
var origFetch = window.fetch;
|
|
window.fetch = function(){
|
|
var u;
|
|
try { u = (typeof arguments[0] === 'string') ? arguments[0] : (arguments[0] && arguments[0].url); } catch(e){}
|
|
var watched = ('' + u).indexOf('ext-api') >= 0;
|
|
return origFetch.apply(this, arguments).then(function(r){
|
|
if (watched) {
|
|
try { r.clone().text().then(function(t){ log('FETCH ' + r.status + ' ' + u + ' :: ' + t.slice(0, 200)); }); } catch(e){}
|
|
}
|
|
return r;
|
|
}).catch(function(err){ if (watched) log('FETCH ERR ' + u + ' ' + err); throw err; });
|
|
};
|
|
} catch(e){}
|
|
}
|
|
function ssKey(k){ return 'komet_did_ss_' + k; }
|
|
function userId(){
|
|
try {
|
|
var h = decodeURIComponent(decodeURIComponent(location.hash || ''));
|
|
var m = h.match(/"id"\s*:\s*(\d+)/);
|
|
if (m) return m[1];
|
|
} catch(e){}
|
|
try {
|
|
var m2 = (location.hash || '').match(/id\W{1,8}?(\d{4,})/);
|
|
if (m2) return m2[1];
|
|
} catch(e){}
|
|
return 'anon';
|
|
}
|
|
try {
|
|
var uid = userId();
|
|
if (localStorage.getItem('komet_did_owner') !== uid) {
|
|
try { localStorage.clear(); } catch(e){}
|
|
try { sessionStorage.clear(); } catch(e){}
|
|
try {
|
|
if (window.indexedDB && indexedDB.databases) {
|
|
indexedDB.databases().then(function(dbs){
|
|
(dbs || []).forEach(function(db){ try { indexedDB.deleteDatabase(db.name); } catch(e){} });
|
|
});
|
|
}
|
|
} catch(e){}
|
|
localStorage.setItem('komet_did_owner', uid);
|
|
}
|
|
} catch(e){}
|
|
function reply(type, data){
|
|
setTimeout(function(){
|
|
try { window.WebApp.receiveEvent(type, data); } catch(e){}
|
|
}, 0);
|
|
}
|
|
function bioToken(){
|
|
try {
|
|
var k = 'komet_did_bio_token';
|
|
var v = localStorage.getItem(k);
|
|
if (!v) {
|
|
v = '';
|
|
for (var i = 0; i < 32; i++) v += Math.floor(Math.random() * 16).toString(16);
|
|
localStorage.setItem(k, v);
|
|
}
|
|
return v;
|
|
} catch(e){ return 'komet-did-fallback-token'; }
|
|
}
|
|
function tokenSaved(){
|
|
try { return !!localStorage.getItem('komet_did_bio_token'); } catch(e){ return false; }
|
|
}
|
|
function handle(type, dataStr){
|
|
var data = {};
|
|
try { data = JSON.parse(dataStr || '{}'); } catch(e){}
|
|
log('recv ' + type + ' ' + dataStr);
|
|
var requestId = data.requestId;
|
|
switch (type) {
|
|
case 'WebAppBiometryGetInfo':
|
|
reply(type, {
|
|
requestId: requestId, available: true,
|
|
access_requested: tokenSaved(), accessRequested: tokenSaved(),
|
|
access_granted: tokenSaved(), accessGranted: tokenSaved(),
|
|
token_saved: tokenSaved(), tokenSaved: tokenSaved(),
|
|
device_id: 'komet-device', deviceId: 'komet-device',
|
|
type: 'face', biometricType: 'face'
|
|
});
|
|
return;
|
|
case 'WebAppBiometryRequestAccess':
|
|
reply(type, { requestId: requestId, granted: true, access_granted: true, accessGranted: true, status: 'granted' });
|
|
return;
|
|
case 'WebAppBiometryAuthenticate':
|
|
reply(type, { requestId: requestId, token: bioToken(), success: true, status: 'authenticated' });
|
|
return;
|
|
case 'WebAppBiometryUpdateToken':
|
|
case 'WebAppBiometryUpdateBiometricToken':
|
|
reply(type, { requestId: requestId, success: true, status: 'updated' });
|
|
return;
|
|
case 'WebAppOpenLink':
|
|
sawOpenLink = true;
|
|
if (data && data.url) {
|
|
setTimeout(function(){
|
|
try { window.location.assign(data.url); } catch(e){}
|
|
}, 0);
|
|
}
|
|
return;
|
|
case 'WebAppClose':
|
|
if (!sawOpenLink) {
|
|
try { window.flutter_inappwebview.callHandler('closeWebApp'); } catch(e){}
|
|
}
|
|
return;
|
|
default:
|
|
if (type.indexOf('SecureStorage') >= 0 || type.indexOf('DeviceStorage') >= 0) {
|
|
var key = data.key;
|
|
if (/Set|Save|Put/i.test(type)) {
|
|
try { localStorage.setItem(ssKey(key), JSON.stringify(data.value !== undefined ? data.value : null)); } catch(e){}
|
|
reply(type, { requestId: requestId, success: true });
|
|
} else if (/Remove|Delete|Clear/i.test(type)) {
|
|
try { localStorage.removeItem(ssKey(key)); } catch(e){}
|
|
reply(type, { requestId: requestId, success: true });
|
|
} else {
|
|
var val = null;
|
|
try {
|
|
var raw = localStorage.getItem(ssKey(key));
|
|
val = (raw == null) ? null : JSON.parse(raw);
|
|
} catch(e){}
|
|
reply(type, { requestId: requestId, value: val, data: val });
|
|
}
|
|
return;
|
|
}
|
|
if (requestId != null) reply(type, { requestId: requestId });
|
|
}
|
|
}
|
|
try {
|
|
window.WebViewHandler = {
|
|
postEvent: function(type, dataStr){
|
|
try { handle(type, dataStr); } catch(e){}
|
|
}
|
|
};
|
|
} catch(e){}
|
|
})();
|
|
''';
|
|
|
|
class DigitalIdWebScreen extends StatefulWidget {
|
|
const DigitalIdWebScreen({super.key});
|
|
|
|
@override
|
|
State<DigitalIdWebScreen> createState() => _DigitalIdWebScreenState();
|
|
}
|
|
|
|
class _DigitalIdWebScreenState extends State<DigitalIdWebScreen> {
|
|
InAppWebViewController? _controller;
|
|
WebAppLaunch? _launch;
|
|
String? _loadError;
|
|
double _progress = 0;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_load();
|
|
}
|
|
|
|
Future<void> _load() async {
|
|
setState(() {
|
|
_loadError = null;
|
|
_launch = null;
|
|
});
|
|
try {
|
|
final launch = await webAppModule.fetchDigitalId();
|
|
if (!mounted) return;
|
|
setState(() => _launch = launch);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
setState(() => _loadError = e.toString());
|
|
}
|
|
}
|
|
|
|
Future<bool> _handleBack() async {
|
|
final controller = _controller;
|
|
if (controller != null && await controller.canGoBack()) {
|
|
await controller.goBack();
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final cs = Theme.of(context).colorScheme;
|
|
return PopScope(
|
|
canPop: false,
|
|
onPopInvokedWithResult: (didPop, result) async {
|
|
if (didPop) return;
|
|
final navigator = Navigator.of(context);
|
|
if (await _handleBack()) navigator.pop();
|
|
},
|
|
child: Scaffold(
|
|
backgroundColor: cs.surface,
|
|
floatingActionButtonLocation: FloatingActionButtonLocation.startFloat,
|
|
floatingActionButton: const ConnectionSpinner(),
|
|
appBar: AppBar(
|
|
backgroundColor: cs.surface,
|
|
surfaceTintColor: Colors.transparent,
|
|
title: const Text('Цифровой ID'),
|
|
leading: IconButton(
|
|
icon: const Icon(Symbols.close),
|
|
onPressed: () => Navigator.of(context).maybePop(),
|
|
),
|
|
actions: [
|
|
IconButton(
|
|
icon: const Icon(Symbols.refresh),
|
|
onPressed: _launch == null ? null : () => _controller?.reload(),
|
|
),
|
|
],
|
|
bottom: _progress > 0 && _progress < 1
|
|
? PreferredSize(
|
|
preferredSize: const Size.fromHeight(2),
|
|
child: LinearProgressIndicator(
|
|
value: _progress,
|
|
minHeight: 2,
|
|
backgroundColor: Colors.transparent,
|
|
),
|
|
)
|
|
: null,
|
|
),
|
|
body: _buildBody(cs),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildBody(ColorScheme cs) {
|
|
if (_loadError != null) {
|
|
return _ErrorView(message: _loadError!, onRetry: _load);
|
|
}
|
|
final launch = _launch;
|
|
if (launch == null) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
return InAppWebView(
|
|
initialUrlRequest: URLRequest(url: WebUri(launch.url)),
|
|
initialUserScripts: UnmodifiableListView<UserScript>([
|
|
UserScript(
|
|
source: 'window.__KOMET_DID_DEBUG=$kDebugMode;',
|
|
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
|
|
),
|
|
UserScript(
|
|
source: _kBridge,
|
|
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
|
|
),
|
|
]),
|
|
initialSettings: InAppWebViewSettings(
|
|
javaScriptEnabled: true,
|
|
domStorageEnabled: true,
|
|
thirdPartyCookiesEnabled: true,
|
|
supportZoom: false,
|
|
transparentBackground: true,
|
|
mediaPlaybackRequiresUserGesture: false,
|
|
useHybridComposition: true,
|
|
useShouldOverrideUrlLoading: true,
|
|
),
|
|
onWebViewCreated: (controller) {
|
|
_controller = controller;
|
|
controller.addJavaScriptHandler(
|
|
handlerName: 'closeWebApp',
|
|
callback: (args) {
|
|
if (mounted) Navigator.of(context).maybePop();
|
|
return null;
|
|
},
|
|
);
|
|
},
|
|
onPermissionRequest: (controller, request) =>
|
|
askWebViewPermission(context, request),
|
|
onConsoleMessage: kDebugMode
|
|
? (controller, consoleMessage) {
|
|
debugPrint('[KOMET-DID] ${consoleMessage.message}');
|
|
}
|
|
: null,
|
|
onLoadStart: kDebugMode
|
|
? (controller, url) {
|
|
final u = url?.toString() ?? '';
|
|
debugPrint('[KOMET-DID] loadStart: ${u.length > 160 ? u.substring(0, 160) : u}');
|
|
}
|
|
: null,
|
|
shouldOverrideUrlLoading: (controller, action) async {
|
|
final uri = action.request.url;
|
|
final url = uri?.toString() ?? '';
|
|
final scheme = uri?.scheme ?? '';
|
|
if (kDebugMode) {
|
|
debugPrint('[KOMET-DID] nav: ${url.length > 140 ? url.substring(0, 140) : url}');
|
|
}
|
|
final isCallback = url.contains('?externalCallback=') ||
|
|
url.contains('&externalCallback=');
|
|
if (isCallback || (scheme != 'http' && scheme != 'https')) {
|
|
final launchUrl = _launch?.url ?? 'https://digital-id.max.ru';
|
|
final hashIdx = launchUrl.indexOf('#');
|
|
final base = hashIdx >= 0 ? launchUrl.substring(0, hashIdx) : launchUrl;
|
|
final frag = hashIdx >= 0 ? launchUrl.substring(hashIdx) : '';
|
|
final query = uri?.query ?? '';
|
|
final target = query.isEmpty ? launchUrl : '$base?$query$frag';
|
|
controller.loadUrl(urlRequest: URLRequest(url: WebUri(target)));
|
|
return NavigationActionPolicy.CANCEL;
|
|
}
|
|
return NavigationActionPolicy.ALLOW;
|
|
},
|
|
onProgressChanged: (controller, progress) {
|
|
if (!mounted) return;
|
|
setState(() => _progress = progress / 100);
|
|
},
|
|
onReceivedError: (controller, request, error) {
|
|
if (!mounted) return;
|
|
if (request.isForMainFrame ?? false) {
|
|
setState(() => _loadError = error.description);
|
|
}
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ErrorView extends StatelessWidget {
|
|
final String message;
|
|
final VoidCallback onRetry;
|
|
|
|
const _ErrorView({required this.message, required this.onRetry});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final cs = Theme.of(context).colorScheme;
|
|
return Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(32),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Symbols.cloud_off, size: 48, color: cs.onSurfaceVariant),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
message,
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
|
|
),
|
|
const SizedBox(height: 24),
|
|
FilledButton(onPressed: onRetry, child: const Text('Повторить')),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|