feat: завершение веб-сессии и панель состояния подписки

This commit is contained in:
klockky
2026-08-22 13:52:09 +03:00
parent d9e29f8651
commit 101262897f
7 changed files with 264 additions and 13 deletions
+92 -1
View File
@@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:io' show Platform; import 'dart:io' show Platform;
import 'dart:ui' show PlatformDispatcher;
import 'package:flutter/foundation.dart';
import 'package:device_info_plus/device_info_plus.dart'; import 'package:device_info_plus/device_info_plus.dart';
@@ -22,6 +23,26 @@ class WebPushSubscription {
}); });
} }
class WebPushLinkInfo {
final String endpoint;
final DateTime? linkedAt;
final String deviceId;
const WebPushLinkInfo({
required this.endpoint,
required this.linkedAt,
required this.deviceId,
});
String get shortEndpoint {
final token = endpoint.split('/').last;
if (token.length <= 24) return token;
return '${token.substring(0, 12)}${token.substring(token.length - 8)}';
}
String get host => Uri.tryParse(endpoint)?.host ?? endpoint;
}
class WebPushQrTrack { class WebPushQrTrack {
final String trackId; final String trackId;
final String qrLink; final String qrLink;
@@ -64,6 +85,7 @@ class WebPushService {
static const String _tokenKey = 'webpush_login_token'; static const String _tokenKey = 'webpush_login_token';
static const String _deviceIdKey = 'webpush_device_id'; static const String _deviceIdKey = 'webpush_device_id';
static const String _endpointKey = 'webpush_endpoint'; static const String _endpointKey = 'webpush_endpoint';
static const String _linkedAtKey = 'webpush_linked_at';
static const String _appVersion = '26.8.8'; static const String _appVersion = '26.8.8';
static const Duration _defaultPoll = Duration(seconds: 5); static const Duration _defaultPoll = Duration(seconds: 5);
@@ -72,11 +94,27 @@ class WebPushService {
MaxWebSocketSession? _authSocket; MaxWebSocketSession? _authSocket;
MaxWebDevice? _device; MaxWebDevice? _device;
final ValueNotifier<int> changes = ValueNotifier<int>(0);
void _notifyChanged() => changes.value++;
Future<bool> isAuthorized() async => Future<bool> isAuthorized() async =>
(await TokenStorage.readSecure(_tokenKey))?.isNotEmpty ?? false; (await TokenStorage.readSecure(_tokenKey))?.isNotEmpty ?? false;
Future<String?> linkedEndpoint() => TokenStorage.readSecure(_endpointKey); Future<String?> linkedEndpoint() => TokenStorage.readSecure(_endpointKey);
Future<WebPushLinkInfo?> linkInfo() async {
final endpoint = await TokenStorage.readSecure(_endpointKey);
if (endpoint == null || endpoint.isEmpty) return null;
final stamp = await TokenStorage.readSecure(_linkedAtKey);
return WebPushLinkInfo(
endpoint: endpoint,
linkedAt: stamp == null ? null : DateTime.tryParse(stamp),
deviceId: await deviceId(),
);
}
Future<String> deviceId() async { Future<String> deviceId() async {
final saved = await TokenStorage.readSecure(_deviceIdKey); final saved = await TokenStorage.readSecure(_deviceIdKey);
if (saved != null && saved.isNotEmpty) return saved; if (saved != null && saved.isNotEmpty) return saved;
@@ -171,6 +209,7 @@ class WebPushService {
Future<void> finishAuth(String loginToken) async { Future<void> finishAuth(String loginToken) async {
await TokenStorage.writeSecure(_tokenKey, loginToken); await TokenStorage.writeSecure(_tokenKey, loginToken);
await cancelAuth(); await cancelAuth();
_notifyChanged();
logger.i('WebPush: WEB-сессия авторизована по QR'); logger.i('WebPush: WEB-сессия авторизована по QR');
} }
@@ -205,6 +244,11 @@ class WebPushService {
'publicKey': subscription.publicKey, 'publicKey': subscription.publicKey,
}); });
await TokenStorage.writeSecure(_endpointKey, subscription.endpoint); await TokenStorage.writeSecure(_endpointKey, subscription.endpoint);
await TokenStorage.writeSecure(
_linkedAtKey,
DateTime.now().toIso8601String(),
);
_notifyChanged();
logger.i('WebPush: подписка зарегистрирована'); logger.i('WebPush: подписка зарегистрирована');
} finally { } finally {
await socket.close(); await socket.close();
@@ -213,8 +257,55 @@ class WebPushService {
Future<void> signOut() async { Future<void> signOut() async {
await cancelAuth(); await cancelAuth();
final token = await TokenStorage.readSecure(_tokenKey);
final endpoint = await TokenStorage.readSecure(_endpointKey);
if (token != null && token.isNotEmpty) {
try {
await _terminateWebSession(token, endpoint);
} catch (e) {
logger.w('WebPush: веб-сессию завершить не удалось ($e)');
}
}
await TokenStorage.deleteSecure(_tokenKey); await TokenStorage.deleteSecure(_tokenKey);
await TokenStorage.deleteSecure(_endpointKey); await TokenStorage.deleteSecure(_endpointKey);
await TokenStorage.deleteSecure(_linkedAtKey);
_notifyChanged();
}
Future<void> _terminateWebSession(String token, String? endpoint) async {
final socket = MaxWebSocketSession(device: await device());
try {
await socket.connect();
await socket.request(Opcode.login, <String, Object?>{
'token': token,
'chatsCount': 0,
'interactive': false,
'chatsSync': 0,
'contactsSync': 0,
'presenceSync': -1,
'draftsSync': 0,
});
if (endpoint != null && endpoint.isNotEmpty) {
try {
await socket.request(Opcode.config, <String, Object?>{
'subscribe': false,
'pushToken': endpoint,
'secretKey': '',
'publicKey': '',
});
} catch (e) {
logger.w('WebPush: подписку снять не удалось ($e)');
}
}
await socket.request(Opcode.logout, <String, Object?>{});
logger.i('WebPush: веб-сессия завершена');
} finally {
await socket.close();
}
} }
MaxWebSocketSession _requireSocket() { MaxWebSocketSession _requireSocket() {
@@ -3,12 +3,14 @@ import 'package:flutter/services.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import '../../../backend/api.dart'; import '../../../backend/api.dart';
import '../../../core/utils/format.dart';
import '../../../core/utils/haptics.dart'; import '../../../core/utils/haptics.dart';
import '../../../core/utils/link_opener.dart'; import '../../../core/utils/link_opener.dart';
import '../../../core/webpush/max_web_socket.dart'; import '../../../core/webpush/max_web_socket.dart';
import '../../../core/webpush/web_push_service.dart'; import '../../../core/webpush/web_push_service.dart';
import '../../../l10n/app_localizations.dart'; import '../../../l10n/app_localizations.dart';
import '../../../main.dart' show accountModule, api; import '../../../main.dart' show accountModule, api;
import '../../widgets/confirm_dialog.dart';
import '../../widgets/connection_status.dart'; import '../../widgets/connection_status.dart';
import '../../widgets/custom_notification.dart'; import '../../widgets/custom_notification.dart';
import '../../widgets/section_header.dart'; import '../../widgets/section_header.dart';
@@ -36,19 +38,26 @@ class _WebPushScreenState extends State<WebPushScreen> {
_Stage _stage = _Stage.loading; _Stage _stage = _Stage.loading;
bool _busy = false; bool _busy = false;
bool _linked = false; bool _linked = false;
WebPushLinkInfo? _link;
String? _trackId; String? _trackId;
String? _passwordHint; String? _passwordHint;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WebPushService.instance.changes.addListener(_onServiceChanged);
_reload(); _reload();
} }
void _onServiceChanged() {
if (mounted) _reload();
}
@override @override
void dispose() { void dispose() {
final listener = _routeAnimationListener; final listener = _routeAnimationListener;
if (listener != null) _routeAnimation?.removeStatusListener(listener); if (listener != null) _routeAnimation?.removeStatusListener(listener);
WebPushService.instance.changes.removeListener(_onServiceChanged);
_passwordController.dispose(); _passwordController.dispose();
_passwordFocus.dispose(); _passwordFocus.dispose();
WebPushService.instance.cancelAuth(); WebPushService.instance.cancelAuth();
@@ -58,10 +67,11 @@ class _WebPushScreenState extends State<WebPushScreen> {
Future<void> _reload() async { Future<void> _reload() async {
final service = WebPushService.instance; final service = WebPushService.instance;
final authorized = await service.isAuthorized(); final authorized = await service.isAuthorized();
final endpoint = await service.linkedEndpoint(); final link = await service.linkInfo();
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_linked = endpoint != null && endpoint.isNotEmpty; _link = link;
_linked = link != null;
_stage = authorized ? _Stage.ready : _Stage.intro; _stage = authorized ? _Stage.ready : _Stage.intro;
}); });
} }
@@ -152,15 +162,28 @@ class _WebPushScreenState extends State<WebPushScreen> {
setState(() => _stage = _Stage.ready); setState(() => _stage = _Stage.ready);
} }
Future<void> _signOut() => _run(() async { Future<void> _signOut() async {
await WebPushService.instance.signOut(); final l10n = AppLocalizations.of(context)!;
if (!mounted) return; final confirmed = await showConfirmDialog(
setState(() { context,
_linked = false; title: l10n.webPushSignOut,
_trackId = null; message: l10n.webPushSignOutConfirm,
_stage = _Stage.intro; confirmLabel: l10n.webPushSignOutAction,
destructive: true,
);
if (!confirmed || !mounted) return;
await _run(() async {
await WebPushService.instance.signOut();
if (!mounted) return;
setState(() {
_linked = false;
_link = null;
_trackId = null;
_stage = _Stage.intro;
});
}); });
}); }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -243,6 +266,10 @@ class _WebPushScreenState extends State<WebPushScreen> {
fontSize: 14, fontSize: 14,
), ),
_explainer(cs, _linked ? l10n.webPushLinkedBody : l10n.webPushInstallBody), _explainer(cs, _linked ? l10n.webPushLinkedBody : l10n.webPushInstallBody),
if (_link != null) ...[
const SizedBox(height: 12),
_linkDetails(cs, l10n, _link!),
],
const SizedBox(height: 20), const SizedBox(height: 20),
_primary(l10n.webPushOpenSite, () { _primary(l10n.webPushOpenSite, () {
Haptics.tap(); Haptics.tap();
@@ -263,6 +290,53 @@ class _WebPushScreenState extends State<WebPushScreen> {
], ],
}; };
Widget _detailRow(ColorScheme cs, String label, String value) => Padding(
padding: const EdgeInsets.symmetric(vertical: 5),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 104,
child: Text(
label,
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
),
),
Expanded(
child: Text(
value,
style: TextStyle(
color: cs.onSurface,
fontSize: 13,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
),
],
),
);
Widget _linkDetails(
ColorScheme cs,
AppLocalizations l10n,
WebPushLinkInfo link,
) => SettingsPanel(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_detailRow(cs, l10n.webPushStatusService, link.host),
_detailRow(cs, l10n.webPushStatusToken, link.shortEndpoint),
if (link.linkedAt != null)
_detailRow(
cs,
l10n.webPushStatusLinkedAt,
formatDateTimeWords(link.linkedAt!),
),
_detailRow(cs, l10n.webPushStatusDevice, link.deviceId),
],
),
);
Widget _explainer(ColorScheme cs, String text) => SettingsPanel( Widget _explainer(ColorScheme cs, String text) => SettingsPanel(
child: Text( child: Text(
text, text,
+7 -1
View File
@@ -1209,5 +1209,11 @@
"webPushNotAuthorized": "Sign in under \"Notifications via PWA\" first", "webPushNotAuthorized": "Sign in under \"Notifications via PWA\" first",
"webPushConnect": "Connect notifications", "webPushConnect": "Connect notifications",
"webPushWaitingBody": "Komet is approving the web session from this device. This usually takes a few seconds.", "webPushWaitingBody": "Komet is approving the web session from this device. This usually takes a few seconds.",
"webPushNeedsOnline": "No connection to the server. Wait for it and try again." "webPushNeedsOnline": "No connection to the server. Wait for it and try again.",
"webPushSignOutConfirm": "The web session will be terminated and disappear from your device list. To get notifications back you will have to connect again.",
"webPushSignOutAction": "Disconnect",
"webPushStatusService": "Service",
"webPushStatusToken": "Token",
"webPushStatusLinkedAt": "Linked",
"webPushStatusDevice": "Device"
} }
+36
View File
@@ -5317,6 +5317,42 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'No connection to the server. Wait for it and try again.'** /// **'No connection to the server. Wait for it and try again.'**
String get webPushNeedsOnline; String get webPushNeedsOnline;
/// No description provided for @webPushSignOutConfirm.
///
/// In en, this message translates to:
/// **'The web session will be terminated and disappear from your device list. To get notifications back you will have to connect again.'**
String get webPushSignOutConfirm;
/// No description provided for @webPushSignOutAction.
///
/// In en, this message translates to:
/// **'Disconnect'**
String get webPushSignOutAction;
/// No description provided for @webPushStatusService.
///
/// In en, this message translates to:
/// **'Service'**
String get webPushStatusService;
/// No description provided for @webPushStatusToken.
///
/// In en, this message translates to:
/// **'Token'**
String get webPushStatusToken;
/// No description provided for @webPushStatusLinkedAt.
///
/// In en, this message translates to:
/// **'Linked'**
String get webPushStatusLinkedAt;
/// No description provided for @webPushStatusDevice.
///
/// In en, this message translates to:
/// **'Device'**
String get webPushStatusDevice;
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate
+19
View File
@@ -2806,4 +2806,23 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get webPushNeedsOnline => String get webPushNeedsOnline =>
'No connection to the server. Wait for it and try again.'; 'No connection to the server. Wait for it and try again.';
@override
String get webPushSignOutConfirm =>
'The web session will be terminated and disappear from your device list. To get notifications back you will have to connect again.';
@override
String get webPushSignOutAction => 'Disconnect';
@override
String get webPushStatusService => 'Service';
@override
String get webPushStatusToken => 'Token';
@override
String get webPushStatusLinkedAt => 'Linked';
@override
String get webPushStatusDevice => 'Device';
} }
+19
View File
@@ -2818,4 +2818,23 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get webPushNeedsOnline => String get webPushNeedsOnline =>
'Нет связи с сервером. Дождитесь подключения и попробуйте снова.'; 'Нет связи с сервером. Дождитесь подключения и попробуйте снова.';
@override
String get webPushSignOutConfirm =>
'Веб-сессия будет завершена и исчезнет из списка устройств. Чтобы вернуть уведомления, подключение придётся пройти заново.';
@override
String get webPushSignOutAction => 'Отключить';
@override
String get webPushStatusService => 'Сервис';
@override
String get webPushStatusToken => 'Токен';
@override
String get webPushStatusLinkedAt => 'Привязан';
@override
String get webPushStatusDevice => 'Устройство';
} }
+7 -1
View File
@@ -953,5 +953,11 @@
"webPushNotAuthorized": "Сначала войдите в разделе «Уведомления через PWA»", "webPushNotAuthorized": "Сначала войдите в разделе «Уведомления через PWA»",
"webPushConnect": "Подключить уведомления", "webPushConnect": "Подключить уведомления",
"webPushWaitingBody": "Комет подтверждает вход веб-сессии с этого устройства. Обычно занимает несколько секунд.", "webPushWaitingBody": "Комет подтверждает вход веб-сессии с этого устройства. Обычно занимает несколько секунд.",
"webPushNeedsOnline": "Нет связи с сервером. Дождитесь подключения и попробуйте снова." "webPushNeedsOnline": "Нет связи с сервером. Дождитесь подключения и попробуйте снова.",
"webPushSignOutConfirm": "Веб-сессия будет завершена и исчезнет из списка устройств. Чтобы вернуть уведомления, подключение придётся пройти заново.",
"webPushSignOutAction": "Отключить",
"webPushStatusService": "Сервис",
"webPushStatusToken": "Токен",
"webPushStatusLinkedAt": "Привязан",
"webPushStatusDevice": "Устройство"
} }