From 5ac407fb76ee56853895cf3cc3e998857243f931 Mon Sep 17 00:00:00 2001 From: klockky Date: Fri, 21 Aug 2026 21:49:40 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D1=83=D0=B2=D0=B5=D0=B4=D0=BE=D0=BC?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BD=D0=B0=20iOS=20=D1=87?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=B7=20PWA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/links/deep_link_service.dart | 80 ++++ lib/core/webpush/max_web_protocol.dart | 453 ++++++++++++++++++ lib/core/webpush/max_web_socket.dart | 190 ++++++++ lib/core/webpush/web_push_service.dart | 296 ++++++++++++ .../screens/profile/notifications_screen.dart | 20 + .../screens/profile/web_push_screen.dart | 285 +++++++++++ lib/l10n/app_en.arb | 45 +- lib/l10n/app_localizations.dart | 108 +++++ lib/l10n/app_localizations_en.dart | 65 +++ lib/l10n/app_localizations_ru.dart | 65 +++ lib/l10n/app_ru.arb | 45 +- pubspec.lock | 24 +- pubspec.yaml | 4 +- test/max_web_protocol_test.dart | 88 ++++ 14 files changed, 1730 insertions(+), 38 deletions(-) create mode 100644 lib/core/webpush/max_web_protocol.dart create mode 100644 lib/core/webpush/max_web_socket.dart create mode 100644 lib/core/webpush/web_push_service.dart create mode 100644 lib/frontend/screens/profile/web_push_screen.dart create mode 100644 test/max_web_protocol_test.dart diff --git a/lib/core/links/deep_link_service.dart b/lib/core/links/deep_link_service.dart index 745bced..33a56b8 100644 --- a/lib/core/links/deep_link_service.dart +++ b/lib/core/links/deep_link_service.dart @@ -1,8 +1,11 @@ import 'dart:async'; +import 'dart:io' show Platform; import 'package:app_links/app_links.dart'; import 'package:flutter/widgets.dart'; +import '../../l10n/app_localizations.dart'; + import '../../backend/api.dart'; import '../../frontend/debug/log_export.dart'; import '../../frontend/screens/digital_id/digital_id_web_screen.dart'; @@ -10,6 +13,8 @@ import '../../frontend/widgets/custom_notification.dart'; import '../../frontend/widgets/max_link_handler.dart'; import '../../frontend/widgets/swipe_route.dart'; import '../../main.dart'; +import '../webpush/max_web_socket.dart'; +import '../webpush/web_push_service.dart'; import 'desktop_url_scheme.dart'; import 'max_link.dart'; @@ -24,8 +29,10 @@ class DeepLinkService { String? _pending; bool _pendingLogExport = false; String? _pendingExternalCallback; + WebPushSubscription? _pendingWebPush; String? _lastExternalCallback; Timer? _externalCallbackRetry; + Timer? _webPushRetry; Timer? _logExportRetry; bool _ready = false; bool _started = false; @@ -58,6 +65,12 @@ class DeepLinkService { _flushPending(); return; } + final webPush = _parseWebPushLink(uri); + if (webPush != null) { + _pendingWebPush = webPush; + _flushPending(); + return; + } if (_isExternalCallback(uri)) { final callbackUrl = uri.toString(); if (callbackUrl == _lastExternalCallback) return; @@ -100,6 +113,19 @@ class DeepLinkService { } } + if (_pendingWebPush != null) { + if (context == null) { + _webPushRetry ??= Timer(const Duration(milliseconds: 300), () { + _webPushRetry = null; + _flushPending(); + }); + } else { + final subscription = _pendingWebPush!; + _pendingWebPush = null; + _handleWebPush(context, subscription); + } + } + if (!_ready || context == null) return; final pending = _pending; if (pending == null) return; @@ -132,6 +158,58 @@ class DeepLinkService { } } + WebPushSubscription? _parseWebPushLink(Uri uri) { + if (!Platform.isIOS) return null; + if (uri.scheme.toLowerCase() != 'komet') return null; + + final segments = [ + if (uri.host.isNotEmpty) uri.host, + ...uri.pathSegments, + ].where((s) => s.isNotEmpty).toList(); + if (segments.length != 1 || segments.first != 'webpush') return null; + + final endpoint = uri.queryParameters['endpoint'] ?? ''; + final publicKey = uri.queryParameters['p256dh'] ?? ''; + final authKey = uri.queryParameters['auth'] ?? ''; + if (endpoint.isEmpty || publicKey.isEmpty || authKey.isEmpty) return null; + if (Uri.tryParse(endpoint)?.isScheme('https') != true) return null; + + return WebPushSubscription( + endpoint: endpoint, + publicKey: publicKey, + authKey: authKey, + ); + } + + Future _handleWebPush( + BuildContext context, + WebPushSubscription subscription, + ) async { + final l10n = AppLocalizations.of(context)!; + + if (!await WebPushService.instance.isAuthorized()) { + if (context.mounted) { + showCustomNotification(context, l10n.webPushNotAuthorized); + } + return; + } + + try { + await WebPushService.instance.registerSubscription(subscription); + if (context.mounted) { + showCustomNotification(context, l10n.webPushLinked); + } + } on MaxWebException catch (e) { + if (context.mounted) { + showCustomNotification(context, l10n.webPushLinkFailed(e.message)); + } + } catch (e) { + if (context.mounted) { + showCustomNotification(context, l10n.webPushLinkFailed('$e')); + } + } + } + bool _isLogExportLink(Uri uri) { final scheme = uri.scheme.toLowerCase(); final host = uri.host.toLowerCase(); @@ -176,6 +254,8 @@ class DeepLinkService { void dispose() { _logExportRetry?.cancel(); _logExportRetry = null; + _webPushRetry?.cancel(); + _webPushRetry = null; _sub?.cancel(); _sub = null; _stateSub?.cancel(); diff --git a/lib/core/webpush/max_web_protocol.dart b/lib/core/webpush/max_web_protocol.dart new file mode 100644 index 0000000..65f0830 --- /dev/null +++ b/lib/core/webpush/max_web_protocol.dart @@ -0,0 +1,453 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +class MaxWebFrame { + final int cmd; + final int seq; + final int opcode; + final Object? payload; + + const MaxWebFrame({ + required this.cmd, + required this.seq, + required this.opcode, + this.payload, + }); + + bool get isOk => cmd == MaxWebCmd.ok; + bool get isError => cmd != MaxWebCmd.ok && cmd != MaxWebCmd.request; +} + +abstract class MaxWebCmd { + static const int request = 0; + static const int ok = 1; + static const int notFound = 2; + static const int error = 3; +} + +abstract class MaxWebFraming { + static const int protocolVersion = 10; + static const int headerSize = 10; + + static Uint8List encode({ + required int cmd, + required int seq, + required int opcode, + Object? payload, + }) { + final body = payload == null + ? Uint8List(0) + : MaxMsgpack.encode(payload); + final frame = Uint8List(headerSize + body.length); + final view = ByteData.view(frame.buffer); + + view.setUint8(0, protocolVersion); + view.setUint8(1, cmd); + view.setInt16(2, seq); + view.setInt16(4, opcode); + view.setUint8(6, 0); + view.setUint8(7, (body.length >> 16) & 0xFF); + view.setUint8(8, (body.length >> 8) & 0xFF); + view.setUint8(9, body.length & 0xFF); + + frame.setRange(headerSize, frame.length, body); + return frame; + } + + static MaxWebFrame decode(Uint8List frame) { + if (frame.length < headerSize) { + throw const FormatException('MaxWebFraming: кадр короче заголовка'); + } + final view = ByteData.view(frame.buffer, frame.offsetInBytes, frame.length); + + final cmd = view.getUint8(1); + final seq = view.getInt16(2); + final opcode = view.getInt16(4); + final compressionRatio = view.getUint8(6); + final length = + (view.getUint8(7) << 16) | (view.getUint8(8) << 8) | view.getUint8(9); + + if (length <= 0) { + return MaxWebFrame(cmd: cmd, seq: seq, opcode: opcode); + } + + var body = Uint8List.sublistView(frame, headerSize, headerSize + length); + if (compressionRatio > 0) { + body = Lz4Block.decompress(body, length * compressionRatio * 16); + } + + return MaxWebFrame( + cmd: cmd, + seq: seq, + opcode: opcode, + payload: MaxMsgpack.decode(body), + ); + } +} + +abstract class Lz4Block { + static Uint8List decompress(Uint8List source, int maxOutputSize) { + final output = Uint8List(maxOutputSize); + var input = 0; + var written = 0; + + while (input < source.length) { + final token = source[input++]; + + var literalLength = token >> 4; + if (literalLength == 15) { + literalLength += _readLengthExtension(source, () => input, (v) => input = v); + } + + if (written + literalLength > output.length) { + throw const FormatException('Lz4Block: литералы не помещаются'); + } + output.setRange(written, written + literalLength, + Uint8List.sublistView(source, input, input + literalLength)); + written += literalLength; + input += literalLength; + + if (input >= source.length) break; + + final offset = source[input] | (source[input + 1] << 8); + input += 2; + if (offset == 0 || offset > written) { + throw const FormatException('Lz4Block: неверное смещение совпадения'); + } + + var matchLength = token & 0x0F; + if (matchLength == 15) { + matchLength += _readLengthExtension(source, () => input, (v) => input = v); + } + matchLength += 4; + + if (written + matchLength > output.length) { + throw const FormatException('Lz4Block: совпадение не помещается'); + } + + var from = written - offset; + for (var i = 0; i < matchLength; i++) { + output[written++] = output[from++]; + } + } + + return Uint8List.sublistView(output, 0, written); + } + + static int _readLengthExtension( + Uint8List source, + int Function() get, + void Function(int) set, + ) { + var cursor = get(); + var extra = 0; + while (true) { + if (cursor >= source.length) { + throw const FormatException('Lz4Block: обрыв в расширении длины'); + } + final byte = source[cursor++]; + extra += byte; + if (byte != 255) break; + } + set(cursor); + return extra; + } +} + +abstract class MaxMsgpack { + static Uint8List encode(Object? value) { + final sink = BytesBuilder(copy: false); + _write(sink, value); + return sink.takeBytes(); + } + + static Object? decode(Uint8List bytes) => _Reader(bytes).read(); + + static void _write(BytesBuilder sink, Object? value) { + if (value == null) { + sink.addByte(0xC0); + } else if (value is bool) { + sink.addByte(value ? 0xC3 : 0xC2); + } else if (value is int) { + _writeInt(sink, value); + } else if (value is double) { + final buffer = ByteData(9) + ..setUint8(0, 0xCB) + ..setFloat64(1, value); + sink.add(buffer.buffer.asUint8List()); + } else if (value is String) { + _writeString(sink, value); + } else if (value is Uint8List) { + _writeBinary(sink, value); + } else if (value is List) { + _writePrefix(sink, value.length, 0x90, 0xDC, 0xDD); + for (final item in value) { + _write(sink, item); + } + } else if (value is Map) { + _writePrefix(sink, value.length, 0x80, 0xDE, 0xDF); + value.forEach((key, item) { + _write(sink, key); + _write(sink, item); + }); + } else { + throw ArgumentError('MaxMsgpack: неподдерживаемый тип ${value.runtimeType}'); + } + } + + static void _writePrefix( + BytesBuilder sink, + int length, + int fixBase, + int wide16, + int wide32, + ) { + if (length < 16) { + sink.addByte(fixBase | length); + } else if (length < 0x10000) { + sink.addByte(wide16); + sink.add([(length >> 8) & 0xFF, length & 0xFF]); + } else { + sink.addByte(wide32); + sink.add([ + (length >> 24) & 0xFF, + (length >> 16) & 0xFF, + (length >> 8) & 0xFF, + length & 0xFF, + ]); + } + } + + static void _writeString(BytesBuilder sink, String value) { + final utf8Bytes = utf8.encode(value); + final length = utf8Bytes.length; + if (length < 32) { + sink.addByte(0xA0 | length); + } else if (length < 0x100) { + sink.add([0xD9, length]); + } else if (length < 0x10000) { + sink.add([0xDA, (length >> 8) & 0xFF, length & 0xFF]); + } else { + sink.add([ + 0xDB, + (length >> 24) & 0xFF, + (length >> 16) & 0xFF, + (length >> 8) & 0xFF, + length & 0xFF, + ]); + } + sink.add(utf8Bytes); + } + + static void _writeBinary(BytesBuilder sink, Uint8List value) { + final length = value.length; + if (length < 0x100) { + sink.add([0xC4, length]); + } else if (length < 0x10000) { + sink.add([0xC5, (length >> 8) & 0xFF, length & 0xFF]); + } else { + sink.add([ + 0xC6, + (length >> 24) & 0xFF, + (length >> 16) & 0xFF, + (length >> 8) & 0xFF, + length & 0xFF, + ]); + } + sink.add(value); + } + + static void _writeInt(BytesBuilder sink, int value) { + if (value >= 0) { + if (value < 0x80) { + sink.addByte(value); + } else if (value < 0x100) { + sink.add([0xCC, value]); + } else if (value < 0x10000) { + sink.add([0xCD, (value >> 8) & 0xFF, value & 0xFF]); + } else if (value < 0x100000000) { + sink.add([ + 0xCE, + (value >> 24) & 0xFF, + (value >> 16) & 0xFF, + (value >> 8) & 0xFF, + value & 0xFF, + ]); + } else { + final buffer = ByteData(9) + ..setUint8(0, 0xCF) + ..setUint64(1, value); + sink.add(buffer.buffer.asUint8List()); + } + } else if (value >= -32) { + sink.addByte(0xE0 | (value + 32)); + } else if (value >= -128) { + final buffer = ByteData(2) + ..setUint8(0, 0xD0) + ..setInt8(1, value); + sink.add(buffer.buffer.asUint8List()); + } else if (value >= -32768) { + final buffer = ByteData(3) + ..setUint8(0, 0xD1) + ..setInt16(1, value); + sink.add(buffer.buffer.asUint8List()); + } else if (value >= -2147483648) { + final buffer = ByteData(5) + ..setUint8(0, 0xD2) + ..setInt32(1, value); + sink.add(buffer.buffer.asUint8List()); + } else { + final buffer = ByteData(9) + ..setUint8(0, 0xD3) + ..setInt64(1, value); + sink.add(buffer.buffer.asUint8List()); + } + } +} + +class _Reader { + _Reader(this._bytes) : _view = ByteData.view( + _bytes.buffer, + _bytes.offsetInBytes, + _bytes.length, + ); + + final Uint8List _bytes; + final ByteData _view; + int _cursor = 0; + + Object? read() { + final byte = _u8(); + + if (byte <= 0x7F) return byte; + if (byte >= 0xE0) return byte - 256; + if (byte >= 0x80 && byte <= 0x8F) return _map(byte & 0x0F); + if (byte >= 0x90 && byte <= 0x9F) return _list(byte & 0x0F); + if (byte >= 0xA0 && byte <= 0xBF) return _string(byte & 0x1F); + + switch (byte) { + case 0xC0: + return null; + case 0xC2: + return false; + case 0xC3: + return true; + case 0xC4: + return _binary(_u8()); + case 0xC5: + return _binary(_u16()); + case 0xC6: + return _binary(_u32()); + case 0xCA: + final value = _view.getFloat32(_cursor); + _cursor += 4; + return value; + case 0xCB: + final value = _view.getFloat64(_cursor); + _cursor += 8; + return value; + case 0xCC: + return _u8(); + case 0xCD: + return _u16(); + case 0xCE: + return _u32(); + case 0xCF: + final value = _view.getUint64(_cursor); + _cursor += 8; + return value; + case 0xD0: + final value = _view.getInt8(_cursor); + _cursor += 1; + return value; + case 0xD1: + final value = _view.getInt16(_cursor); + _cursor += 2; + return value; + case 0xD2: + final value = _view.getInt32(_cursor); + _cursor += 4; + return value; + case 0xD3: + final value = _view.getInt64(_cursor); + _cursor += 8; + return value; + case 0xD9: + return _string(_u8()); + case 0xDA: + return _string(_u16()); + case 0xDB: + return _string(_u32()); + case 0xDC: + return _list(_u16()); + case 0xDD: + return _list(_u32()); + case 0xDE: + return _map(_u16()); + case 0xDF: + return _map(_u32()); + } + + if (byte >= 0xD4 && byte <= 0xD8) return _ext(1 << (byte - 0xD4)); + if (byte == 0xC7) return _ext(_u8()); + if (byte == 0xC8) return _ext(_u16()); + if (byte == 0xC9) return _ext(_u32()); + + throw FormatException('MaxMsgpack: неизвестный маркер 0x${byte.toRadixString(16)}'); + } + + static const int _numberExtType = 1; + + Object? _ext(int length) { + final type = _u8(); + final data = Uint8List.fromList( + Uint8List.sublistView(_bytes, _cursor, _cursor + length), + ); + _cursor += length; + if (type != _numberExtType) return null; + return _Reader(data).read(); + } + + int _u8() => _bytes[_cursor++]; + + int _u16() { + final value = _view.getUint16(_cursor); + _cursor += 2; + return value; + } + + int _u32() { + final value = _view.getUint32(_cursor); + _cursor += 4; + return value; + } + + String _string(int length) { + final value = utf8.decode( + Uint8List.sublistView(_bytes, _cursor, _cursor + length), + allowMalformed: true, + ); + _cursor += length; + return value; + } + + Uint8List _binary(int length) { + final value = Uint8List.fromList( + Uint8List.sublistView(_bytes, _cursor, _cursor + length), + ); + _cursor += length; + return value; + } + + List _list(int length) => + List.generate(length, (_) => read(), growable: false); + + Map _map(int length) { + final map = {}; + for (var i = 0; i < length; i++) { + final key = read(); + map[key] = read(); + } + return map; + } +} diff --git a/lib/core/webpush/max_web_socket.dart b/lib/core/webpush/max_web_socket.dart new file mode 100644 index 0000000..1ffe8a3 --- /dev/null +++ b/lib/core/webpush/max_web_socket.dart @@ -0,0 +1,190 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import '../utils/logger.dart'; +import 'max_web_protocol.dart'; + +class MaxWebException implements Exception { + final String message; + final String? code; + + const MaxWebException(this.message, {this.code}); + + @override + String toString() => code == null ? message : '$code: $message'; +} + +class MaxWebDevice { + final String deviceId; + final String appVersion; + final String osVersion; + final String deviceName; + final String screen; + final String timezone; + final String locale; + final String userAgent; + + const MaxWebDevice({ + required this.deviceId, + this.appVersion = '26.8.8', + this.osVersion = 'iOS', + this.deviceName = 'Safari', + required this.screen, + this.timezone = 'Europe/Moscow', + this.locale = 'ru', + required this.userAgent, + }); + + Map toHandshakeUserAgent() => { + 'deviceType': 'WEB', + 'pushDeviceType': 'WEBPUSH', + 'locale': locale, + 'deviceLocale': locale, + 'osVersion': osVersion, + 'deviceName': deviceName, + 'headerUserAgent': userAgent, + 'isPwa': true, + 'appVersion': appVersion, + 'screen': screen, + 'timezone': timezone, + }; +} + +class MaxWebSocketSession { + static const String endpoint = 'wss://api.oneme.ru/websocket'; + static const String origin = 'https://web.max.ru'; + static const Duration requestTimeout = Duration(seconds: 30); + + static const int _opcodePing = 1; + static const int _opcodeSessionInit = 6; + + final MaxWebDevice device; + + WebSocket? _socket; + StreamSubscription? _subscription; + final Map> _pending = >{}; + int _seq = 0; + bool _closed = false; + + MaxWebSocketSession({required this.device}); + + Future connect() async { + final socket = await WebSocket.connect( + endpoint, + headers: { + 'Origin': origin, + 'User-Agent': device.userAgent, + }, + ); + _socket = socket; + _subscription = socket.listen( + _onFrame, + onError: (Object error) => _failAll(MaxWebException('сокет: $error')), + onDone: () => _failAll(const MaxWebException('соединение закрыто сервером')), + cancelOnError: true, + ); + + return request(_opcodeSessionInit, { + 'userAgent': device.toHandshakeUserAgent(), + 'deviceId': device.deviceId, + }); + } + + Future request(int opcode, Object? payload) { + final socket = _socket; + if (socket == null || _closed) { + return Future.error( + const MaxWebException('сессия не подключена'), + ); + } + + _seq += 1; + final seq = _seq; + final completer = Completer(); + _pending[seq] = completer; + + socket.add( + MaxWebFraming.encode( + cmd: MaxWebCmd.request, + seq: seq, + opcode: opcode, + payload: payload, + ), + ); + + return completer.future.timeout( + requestTimeout, + onTimeout: () { + _pending.remove(seq); + throw MaxWebException('опкод $opcode: сервер не ответил'); + }, + ); + } + + void _onFrame(dynamic raw) { + if (raw is! List) return; + + final MaxWebFrame frame; + try { + frame = MaxWebFraming.decode(Uint8List.fromList(raw)); + } catch (e) { + logger.w('WebPush: не разобрал кадр ($e)'); + return; + } + + if (frame.cmd == MaxWebCmd.request) { + if (frame.opcode == _opcodePing) { + _socket?.add( + MaxWebFraming.encode( + cmd: MaxWebCmd.ok, + seq: frame.seq, + opcode: _opcodePing, + ), + ); + } + return; + } + + final completer = _pending.remove(frame.seq); + if (completer == null || completer.isCompleted) return; + + if (frame.isError) { + completer.completeError(_errorFrom(frame)); + return; + } + completer.complete(frame.payload); + } + + MaxWebException _errorFrom(MaxWebFrame frame) { + final payload = frame.payload; + if (payload is Map) { + final message = payload['localizedMessage'] ?? payload['message']; + final code = payload['error']; + if (message is String && message.isNotEmpty) { + return MaxWebException(message, code: code is String ? code : null); + } + if (code is String && code.isNotEmpty) { + return MaxWebException(code, code: code); + } + } + return MaxWebException('опкод ${frame.opcode}: ошибка сервера (cmd=${frame.cmd})'); + } + + void _failAll(MaxWebException error) { + for (final completer in _pending.values) { + if (!completer.isCompleted) completer.completeError(error); + } + _pending.clear(); + } + + Future close() async { + if (_closed) return; + _closed = true; + _failAll(const MaxWebException('сессия закрыта')); + await _subscription?.cancel(); + _subscription = null; + await _socket?.close(); + _socket = null; + } +} diff --git a/lib/core/webpush/web_push_service.dart b/lib/core/webpush/web_push_service.dart new file mode 100644 index 0000000..75a6544 --- /dev/null +++ b/lib/core/webpush/web_push_service.dart @@ -0,0 +1,296 @@ +import 'dart:async'; +import 'dart:io' show Platform; +import 'dart:ui' show PlatformDispatcher; + +import 'package:device_info_plus/device_info_plus.dart'; + +import '../protocol/opcode_map.dart'; +import '../storage/token_storage.dart'; +import '../utils/ids.dart'; +import '../utils/logger.dart'; +import 'max_web_socket.dart'; + +class WebPushSubscription { + final String endpoint; + final String publicKey; + final String authKey; + + const WebPushSubscription({ + required this.endpoint, + required this.publicKey, + required this.authKey, + }); +} + +class WebPushQrTrack { + final String trackId; + final String qrLink; + final Duration pollInterval; + final Duration lifetime; + + const WebPushQrTrack({ + required this.trackId, + required this.qrLink, + required this.pollInterval, + required this.lifetime, + }); +} + +class WebPushPasswordChallenge { + final String trackId; + final String? hint; + + const WebPushPasswordChallenge({required this.trackId, this.hint}); +} + +class WebPushAuthStep { + final String? loginToken; + final WebPushPasswordChallenge? passwordChallenge; + + const WebPushAuthStep({this.loginToken, this.passwordChallenge}); + + bool get needsPassword => loginToken == null && passwordChallenge != null; +} + +class WebPushService { + WebPushService._(); + + static final WebPushService instance = WebPushService._(); + + static const int _opcodeQrCreate = 288; + static const int _opcodeQrStatus = 289; + static const int _opcodeQrFinish = 291; + + static const String _tokenKey = 'webpush_login_token'; + static const String _deviceIdKey = 'webpush_device_id'; + static const String _endpointKey = 'webpush_endpoint'; + + static const String _appVersion = '26.8.8'; + static const Duration _defaultPoll = Duration(seconds: 5); + static const Duration _defaultLifetime = Duration(minutes: 2); + + MaxWebSocketSession? _authSocket; + MaxWebDevice? _device; + + Future isAuthorized() async => + (await TokenStorage.readSecure(_tokenKey))?.isNotEmpty ?? false; + + Future linkedEndpoint() => TokenStorage.readSecure(_endpointKey); + + Future deviceId() async { + final saved = await TokenStorage.readSecure(_deviceIdKey); + if (saved != null && saved.isNotEmpty) return saved; + + final generated = uuidV4(); + await TokenStorage.writeSecure(_deviceIdKey, generated); + return generated; + } + + Future device() async { + final cached = _device; + if (cached != null) return cached; + + final built = MaxWebDevice( + deviceId: await deviceId(), + appVersion: _appVersion, + userAgent: await _safariUserAgent(), + screen: _browserScreen(), + ); + _device = built; + return built; + } + + Future startQrAuth() async { + await cancelAuth(); + + final socket = MaxWebSocketSession(device: await device()); + await socket.connect(); + _authSocket = socket; + + final payload = _asMap( + await socket.request(_opcodeQrCreate, null), + 'создание QR', + ); + + final trackId = payload['trackId']; + final qrLink = payload['qrLink']; + if (trackId is! String || qrLink is! String) { + throw const MaxWebException('сервер не вернул ссылку для входа'); + } + + return WebPushQrTrack( + trackId: trackId, + qrLink: qrLink, + pollInterval: _durationFrom(payload['pollingInterval'], _defaultPoll), + lifetime: _durationFrom(payload['ttl'], _defaultLifetime), + ); + } + + Future awaitApproval(WebPushQrTrack track) async { + final socket = _requireSocket(); + final deadline = DateTime.now().add(track.lifetime); + + while (DateTime.now().isBefore(deadline)) { + await Future.delayed(track.pollInterval); + + final payload = _asMap( + await socket.request(_opcodeQrStatus, { + 'trackId': track.trackId, + }), + 'опрос входа', + ); + + final status = payload['status']; + final available = status is Map ? status['loginAvailable'] : null; + if (available != true) continue; + + final finish = _asMap( + await socket.request(_opcodeQrFinish, { + 'trackId': track.trackId, + }), + 'завершение входа', + ); + return _stepFrom(finish); + } + + throw const MaxWebException('время подтверждения истекло'); + } + + Future submitPassword(String trackId, String password) async { + final socket = _requireSocket(); + final payload = _asMap( + await socket.request(Opcode.authLoginCheckPassword, { + 'trackId': trackId, + 'password': password, + }), + 'проверка пароля', + ); + return _stepFrom(payload); + } + + Future finishAuth(String loginToken) async { + await TokenStorage.writeSecure(_tokenKey, loginToken); + await cancelAuth(); + logger.i('WebPush: WEB-сессия авторизована по QR'); + } + + Future cancelAuth() async { + final socket = _authSocket; + _authSocket = null; + await socket?.close(); + } + + Future registerSubscription(WebPushSubscription subscription) async { + final token = await TokenStorage.readSecure(_tokenKey); + if (token == null || token.isEmpty) { + throw const MaxWebException('сначала подключите уведомления в настройках'); + } + + final socket = MaxWebSocketSession(device: await device()); + try { + await socket.connect(); + await socket.request(Opcode.login, { + 'token': token, + 'chatsCount': 0, + 'interactive': false, + 'chatsSync': 0, + 'contactsSync': 0, + 'presenceSync': -1, + 'draftsSync': 0, + }); + await socket.request(Opcode.config, { + 'subscribe': true, + 'pushToken': subscription.endpoint, + 'secretKey': subscription.authKey, + 'publicKey': subscription.publicKey, + }); + await TokenStorage.writeSecure(_endpointKey, subscription.endpoint); + logger.i('WebPush: подписка зарегистрирована'); + } finally { + await socket.close(); + } + } + + Future signOut() async { + await cancelAuth(); + await TokenStorage.deleteSecure(_tokenKey); + await TokenStorage.deleteSecure(_endpointKey); + } + + MaxWebSocketSession _requireSocket() { + final socket = _authSocket; + if (socket == null) { + throw const MaxWebException('сессия входа потеряна, начните заново'); + } + return socket; + } + + WebPushAuthStep _stepFrom(Map payload) { + final attrs = payload['tokenAttrs']; + if (attrs is Map) { + final login = attrs['LOGIN']; + if (login is Map) { + final token = login['token']; + if (token is String && token.isNotEmpty) { + return WebPushAuthStep(loginToken: token); + } + } + } + + final challenge = payload['passwordChallenge']; + if (challenge is Map) { + final trackId = challenge['trackId']; + if (trackId is String && trackId.isNotEmpty) { + final hint = challenge['hint']; + return WebPushAuthStep( + passwordChallenge: WebPushPasswordChallenge( + trackId: trackId, + hint: hint is String && hint.isNotEmpty ? hint : null, + ), + ); + } + } + + throw const MaxWebException('сервер не вернул ни токен, ни запрос пароля'); + } + + Map _asMap(Object? payload, String step) { + if (payload is Map) return payload; + throw MaxWebException('$step: неожиданный ответ сервера'); + } + + Duration _durationFrom(Object? value, Duration fallback) { + if (value is int && value > 0) return Duration(milliseconds: value); + return fallback; + } + + Future _safariUserAgent() async { + var release = '18_5'; + var version = '18.5'; + + if (Platform.isIOS) { + try { + final info = await DeviceInfoPlugin().iosInfo; + final systemVersion = info.systemVersion; + if (systemVersion.isNotEmpty) { + version = systemVersion; + release = systemVersion.replaceAll('.', '_'); + } + } catch (e) { + logger.w('WebPush: не удалось прочитать версию iOS ($e)'); + } + } + + return 'Mozilla/5.0 (iPhone; CPU iPhone OS $release like Mac OS X) ' + 'AppleWebKit/605.1.15 (KHTML, like Gecko) Version/$version ' + 'Mobile/15E148 Safari/604.1'; + } + + String _browserScreen() { + final view = PlatformDispatcher.instance.views.first; + final ratio = view.devicePixelRatio; + final height = (view.physicalSize.height / ratio).round(); + final width = (view.physicalSize.width / ratio).round(); + return '${height}x$width ${ratio.toStringAsFixed(1)}x'; + } +} diff --git a/lib/frontend/screens/profile/notifications_screen.dart b/lib/frontend/screens/profile/notifications_screen.dart index cb5c619..5197ee6 100644 --- a/lib/frontend/screens/profile/notifications_screen.dart +++ b/lib/frontend/screens/profile/notifications_screen.dart @@ -15,6 +15,7 @@ import '../../widgets/custom_notification.dart'; import '../../widgets/section_header.dart'; import '../../widgets/settings_card.dart'; import '../../widgets/small_spinner.dart'; +import 'web_push_screen.dart'; class NotificationsScreen extends StatefulWidget { const NotificationsScreen({super.key}); @@ -89,6 +90,12 @@ class _NotificationsScreenState extends State if (mounted) setState(() => _hapticsEnabled = value); } + void _openWebPush() { + Navigator.of(context).push( + MaterialPageRoute(builder: (context) => const WebPushScreen()), + ); + } + Future _onFkmChanged(bool value) async { final l10n = AppLocalizations.of(context)!; if (!FkmController.instance.isSupported) { @@ -159,6 +166,19 @@ class _NotificationsScreenState extends State physics: const BouncingScrollPhysics(), padding: const EdgeInsets.fromLTRB(16, 12, 16, 120), children: [ + if (Platform.isIOS) ...[ + SettingsCard( + children: [ + SettingsNavTile( + icon: Symbols.install_mobile, + label: l10n.webPushTitle, + onTap: _openWebPush, + isLast: true, + ), + ], + ), + const SizedBox(height: 20), + ], SectionHeader( l10n.notificationsFkmSectionTitle, padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), diff --git a/lib/frontend/screens/profile/web_push_screen.dart b/lib/frontend/screens/profile/web_push_screen.dart new file mode 100644 index 0000000..4bd6431 --- /dev/null +++ b/lib/frontend/screens/profile/web_push_screen.dart @@ -0,0 +1,285 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../backend/api.dart'; +import '../../../core/utils/haptics.dart'; +import '../../../core/utils/link_opener.dart'; +import '../../../core/webpush/max_web_socket.dart'; +import '../../../core/webpush/web_push_service.dart'; +import '../../../l10n/app_localizations.dart'; +import '../../../main.dart' show accountModule, api; +import '../../widgets/connection_status.dart'; +import '../../widgets/custom_notification.dart'; +import '../../widgets/section_header.dart'; +import '../../widgets/settings_card.dart'; +import '../../widgets/small_spinner.dart'; + +const String kWebPushSiteUrl = 'https://push.komet.pw'; + +enum _Stage { loading, intro, waiting, password, ready } + +class WebPushScreen extends StatefulWidget { + const WebPushScreen({super.key}); + + @override + State createState() => _WebPushScreenState(); +} + +class _WebPushScreenState extends State { + final _passwordController = TextEditingController(); + final _passwordFocus = FocusNode(); + + Animation? _routeAnimation; + void Function(AnimationStatus)? _routeAnimationListener; + + _Stage _stage = _Stage.loading; + bool _busy = false; + bool _linked = false; + String? _trackId; + String? _passwordHint; + + @override + void initState() { + super.initState(); + _reload(); + } + + @override + void dispose() { + final listener = _routeAnimationListener; + if (listener != null) _routeAnimation?.removeStatusListener(listener); + _passwordController.dispose(); + _passwordFocus.dispose(); + WebPushService.instance.cancelAuth(); + super.dispose(); + } + + Future _reload() async { + final service = WebPushService.instance; + final authorized = await service.isAuthorized(); + final endpoint = await service.linkedEndpoint(); + if (!mounted) return; + setState(() { + _linked = endpoint != null && endpoint.isNotEmpty; + _stage = authorized ? _Stage.ready : _Stage.intro; + }); + } + + void _scheduleKeyboard() { + final animation = ModalRoute.of(context)?.animation; + + if (animation == null || animation.status == AnimationStatus.completed) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _openKeyboard(); + }); + return; + } + + final previous = _routeAnimationListener; + if (previous != null) _routeAnimation?.removeStatusListener(previous); + + _routeAnimation = animation; + _routeAnimationListener = (status) { + if (status != AnimationStatus.completed) return; + final listener = _routeAnimationListener; + if (listener != null) animation.removeStatusListener(listener); + _routeAnimationListener = null; + if (mounted) _openKeyboard(); + }; + animation.addStatusListener(_routeAnimationListener!); + } + + void _openKeyboard() { + if (!_passwordFocus.hasFocus) _passwordFocus.requestFocus(); + SystemChannels.textInput.invokeMethod('TextInput.show'); + } + + Future _run(Future Function() action) async { + if (_busy) return; + setState(() => _busy = true); + try { + await action(); + } on MaxWebException catch (e) { + if (mounted) showCustomNotification(context, e.message); + } catch (e) { + if (mounted) showCustomNotification(context, '$e'); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _connect() => _run(() async { + final l10n = AppLocalizations.of(context)!; + if (api.state != SessionState.online) { + showCustomNotification(context, l10n.webPushNeedsOnline); + return; + } + + final service = WebPushService.instance; + final track = await service.startQrAuth(); + if (!mounted) return; + setState(() => _stage = _Stage.waiting); + + await accountModule.authorizeWebQrLogin(track.qrLink); + final step = await service.awaitApproval(track); + await _applyStep(step); + }); + + Future _submitPassword() => _run(() async { + final trackId = _trackId; + if (trackId == null) return; + final step = await WebPushService.instance.submitPassword( + trackId, + _passwordController.text, + ); + await _applyStep(step); + }); + + Future _applyStep(WebPushAuthStep step) async { + if (step.needsPassword) { + if (!mounted) return; + _trackId = step.passwordChallenge!.trackId; + _passwordHint = step.passwordChallenge!.hint; + setState(() => _stage = _Stage.password); + _scheduleKeyboard(); + return; + } + + await WebPushService.instance.finishAuth(step.loginToken!); + if (!mounted) return; + _passwordController.clear(); + setState(() => _stage = _Stage.ready); + } + + Future _signOut() => _run(() async { + await WebPushService.instance.signOut(); + if (!mounted) return; + setState(() { + _linked = false; + _trackId = null; + _stage = _Stage.intro; + }); + }); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; + + return Scaffold( + backgroundColor: cs.surface, + appBar: ConnectionTitleBar( + titleText: l10n.webPushTitle, + backgroundColor: cs.surface, + ), + body: SafeArea( + top: false, + child: _stage == _Stage.loading + ? const Center(child: SmallSpinner(size: 36)) + : ListView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.fromLTRB(16, 12, 16, 120), + children: _sections(context, cs, l10n), + ), + ), + ); + } + + List _sections( + BuildContext context, + ColorScheme cs, + AppLocalizations l10n, + ) => switch (_stage) { + _Stage.loading => const [], + _Stage.intro => [ + _explainer(cs, l10n.webPushIntro), + const SizedBox(height: 20), + _primary(l10n.webPushConnect, _connect), + ], + _Stage.waiting => [ + _explainer(cs, l10n.webPushWaitingBody), + const SizedBox(height: 24), + const Center(child: SmallSpinner(size: 32)), + ], + _Stage.password => [ + _explainer(cs, l10n.webPushPasswordExplainer), + if (_passwordHint != null) ...[ + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text( + l10n.webPushPasswordHintLabel(_passwordHint!), + style: TextStyle(color: cs.tertiary, fontSize: 14), + ), + ), + ], + const SizedBox(height: 20), + TextField( + controller: _passwordController, + focusNode: _passwordFocus, + enabled: !_busy, + autofocus: true, + obscureText: true, + textInputAction: TextInputAction.done, + onSubmitted: (_) => _submitPassword(), + decoration: InputDecoration( + hintText: l10n.webPushPasswordHint, + filled: true, + fillColor: cs.surfaceContainerHigh, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + const SizedBox(height: 16), + _primary(l10n.webPushConfirm, _submitPassword), + ], + _Stage.ready => [ + SectionHeader( + _linked ? l10n.webPushLinkedTitle : l10n.webPushInstallTitle, + padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), + fontSize: 14, + ), + _explainer(cs, _linked ? l10n.webPushLinkedBody : l10n.webPushInstallBody), + const SizedBox(height: 20), + _primary(l10n.webPushOpenSite, () { + Haptics.tap(); + openExternalUrl(context, kWebPushSiteUrl); + }), + const SizedBox(height: 24), + SettingsCard( + children: [ + SettingsNavTile( + icon: Symbols.logout, + label: l10n.webPushSignOut, + tintColor: cs.error, + onTap: _busy ? null : _signOut, + isLast: true, + ), + ], + ), + ], + }; + + Widget _explainer(ColorScheme cs, String text) => SettingsPanel( + child: Text( + text, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14, height: 1.5), + ), + ); + + Widget _primary(String label, VoidCallback? onPressed) => SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _busy ? null : onPressed, + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + ), + child: _busy + ? const SmallSpinner(size: 20) + : Text(label, style: const TextStyle(fontWeight: FontWeight.w600)), + ), + ); +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 55087b4..2bf3de4 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -50,7 +50,6 @@ "codeResendSms": "Resend code via SMS", "codeError2faMissing": "Error: missing data for 2FA", "codeConfirmation2faWarning": "MAX may require 2FA on your account to sign in. If you didn't receive the code, set up 2FA from a client where you're already signed in.", - "proxySettingsTitle": "Proxy", "proxyTypeNone": "Disabled", "proxyTypeSocks5": "SOCKS5", @@ -63,7 +62,6 @@ "proxyDisable": "Disable proxy", "proxySettingsSaved": "Proxy settings applied", "proxyInvalidHostOrPort": "Enter a valid proxy host and port (1–65535)", - "spoofScreenTitle": "Session spoofing", "spoofEnableTitle": "Device spoofing", "spoofEnableSubtitleOn": "Enabled for this account", @@ -207,7 +205,6 @@ "chatInfoHasBots": "has bots:", "chatInfoBlockedCount": "blocked in group:", "chatInfoOfficialStatus": "official status:", - "chatInfoLastChanged": "last changed:", "chatInfoJoined": "joined:", "chatInfoGroupCreated": "group created:", "chatInfoGroupOwner": "group owner:", @@ -375,7 +372,6 @@ "appearancePreviewHowIsIt": "How do you like it?", "appearancePreviewHmm": "hmm...", "appearancePreviewNotBad": "Not bad at all!", - "callKometDetectedNotification": "This person uses Komet! :3", "callStatusConnecting": "Connecting", "callGroupConnecting": "Connecting…", @@ -445,7 +441,6 @@ "callBadgeNoiseSuppression": "Noise suppression", "callBadgeAnimoji": "Animoji", "callInfoNoDataYet": "Data will appear after connecting…", - "hubTitleMenu": "Komet", "hubChatPageTitle": "Anonymous chat", "hubGamesTitle": "Games", @@ -466,7 +461,6 @@ "hubCheckersLost": "You lost", "hubCheckersYourMove": "Your move", "hubCheckersOpponentMove": "Opponent's move…", - "scheduledPickTimeTitle": "When to send", "scheduledEditTitle": "Edit", "scheduledMessageTextHint": "Message text", @@ -485,7 +479,6 @@ "scheduledAttachLocation": "Location", "scheduledAttachForwarded": "Forwarded", "scheduledAttachGeneric": "Attachment", - "contactProfileLoadError": "Error: {error}", "@contactProfileLoadError": { "placeholders": { @@ -510,7 +503,6 @@ "contactProfileInfoDescription": "Description", "contactProfileInfoLink": "Link", "contactProfileInfoFlags": "Flags", - "nfcPeerNameFallback": "Contact #{id}", "@nfcPeerNameFallback": { "placeholders": { @@ -549,7 +541,6 @@ }, "nfcAdded": "Added", "nfcAddContact": "Add contact", - "chatInfoTabGeneralChats": "Common chats", "chatInfoTabMedia": "Media", "chatInfoTabFiles": "Files", @@ -747,7 +738,6 @@ "chatInfoRowComments": "Comments", "chatInfoRowRkn": "Roskomnadzor approved", "chatInfoRowOnlyAdmin": "Admins only", - "securityTitle": "Security", "securityLoadError": "Loading error: {error}", "@securityLoadError": { @@ -801,7 +791,6 @@ } } }, - "passwordEntryWrongPassword": "Wrong password", "passwordEntryConfirmTitle": "Confirm password", "passwordEntryCurrentPasswordHint": "Current password", @@ -1178,5 +1167,37 @@ "videoEditorProcessing": "Processing video…", "videoEditorExportFailed": "Failed to process the video", "videoEditorFrameFailed": "Failed to grab a frame", - "videoEditorQualityTooltip": "Quality" + "videoEditorQualityTooltip": "Quality", + "webPushTitle": "Notifications on iOS", + "webPushIntro": "Komet has no ordinary push on iOS: Apple issues a notification token only to apps signed with a developer certificate, and a sideloaded build never gets one.\n\nThe way around it is a web app on the Home Screen. MAX's own server sends the notifications through Apple, and a separate icon displays them.\n\nThat needs a web session. Komet creates one and approves it itself, from this very device — no phone number or code required.", + "webPushConfirm": "Continue", + "webPushPasswordExplainer": "Two-factor protection is enabled on this account.", + "webPushPasswordHintLabel": "Hint: {hint}", + "@webPushPasswordHintLabel": { + "placeholders": { + "hint": { + "type": "String" + } + } + }, + "webPushPasswordHint": "Password", + "webPushInstallTitle": "Install the web app", + "webPushInstallBody": "Open push.komet.pw in Safari, add it to the Home Screen and launch the icon that appears. Notifications do not work from a browser tab — that is how iOS works.\n\nIn the app, allow notifications, create a subscription and tap \"Open Komet\". The subscription registers itself from there.", + "webPushLinkedTitle": "Notifications connected", + "webPushLinkedBody": "The subscription is registered on the server. Do not delete the Home Screen icon — the notifications go with it.\n\nIf push stops arriving, open the web app and link again: Apple sometimes rotates the subscription address.", + "webPushOpenSite": "Open push.komet.pw", + "webPushSignOut": "Disconnect notifications", + "webPushLinked": "Notifications connected", + "webPushLinkFailed": "Could not connect notifications: {error}", + "@webPushLinkFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "webPushNotAuthorized": "Sign in under \"Notifications via PWA\" first", + "webPushConnect": "Connect notifications", + "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." } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 0b021b1..428e500 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -5149,6 +5149,114 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Quality'** String get videoEditorQualityTooltip; + + /// No description provided for @webPushTitle. + /// + /// In en, this message translates to: + /// **'Notifications on iOS'** + String get webPushTitle; + + /// No description provided for @webPushIntro. + /// + /// In en, this message translates to: + /// **'Komet has no ordinary push on iOS: Apple issues a notification token only to apps signed with a developer certificate, and a sideloaded build never gets one.\n\nThe way around it is a web app on the Home Screen. MAX\'s own server sends the notifications through Apple, and a separate icon displays them.\n\nThat needs a web session. Komet creates one and approves it itself, from this very device — no phone number or code required.'** + String get webPushIntro; + + /// No description provided for @webPushConfirm. + /// + /// In en, this message translates to: + /// **'Continue'** + String get webPushConfirm; + + /// No description provided for @webPushPasswordExplainer. + /// + /// In en, this message translates to: + /// **'Two-factor protection is enabled on this account.'** + String get webPushPasswordExplainer; + + /// No description provided for @webPushPasswordHintLabel. + /// + /// In en, this message translates to: + /// **'Hint: {hint}'** + String webPushPasswordHintLabel(String hint); + + /// No description provided for @webPushPasswordHint. + /// + /// In en, this message translates to: + /// **'Password'** + String get webPushPasswordHint; + + /// No description provided for @webPushInstallTitle. + /// + /// In en, this message translates to: + /// **'Install the web app'** + String get webPushInstallTitle; + + /// No description provided for @webPushInstallBody. + /// + /// In en, this message translates to: + /// **'Open push.komet.pw in Safari, add it to the Home Screen and launch the icon that appears. Notifications do not work from a browser tab — that is how iOS works.\n\nIn the app, allow notifications, create a subscription and tap \"Open Komet\". The subscription registers itself from there.'** + String get webPushInstallBody; + + /// No description provided for @webPushLinkedTitle. + /// + /// In en, this message translates to: + /// **'Notifications connected'** + String get webPushLinkedTitle; + + /// No description provided for @webPushLinkedBody. + /// + /// In en, this message translates to: + /// **'The subscription is registered on the server. Do not delete the Home Screen icon — the notifications go with it.\n\nIf push stops arriving, open the web app and link again: Apple sometimes rotates the subscription address.'** + String get webPushLinkedBody; + + /// No description provided for @webPushOpenSite. + /// + /// In en, this message translates to: + /// **'Open push.komet.pw'** + String get webPushOpenSite; + + /// No description provided for @webPushSignOut. + /// + /// In en, this message translates to: + /// **'Disconnect notifications'** + String get webPushSignOut; + + /// No description provided for @webPushLinked. + /// + /// In en, this message translates to: + /// **'Notifications connected'** + String get webPushLinked; + + /// No description provided for @webPushLinkFailed. + /// + /// In en, this message translates to: + /// **'Could not connect notifications: {error}'** + String webPushLinkFailed(String error); + + /// No description provided for @webPushNotAuthorized. + /// + /// In en, this message translates to: + /// **'Sign in under \"Notifications via PWA\" first'** + String get webPushNotAuthorized; + + /// No description provided for @webPushConnect. + /// + /// In en, this message translates to: + /// **'Connect notifications'** + String get webPushConnect; + + /// No description provided for @webPushWaitingBody. + /// + /// In en, this message translates to: + /// **'Komet is approving the web session from this device. This usually takes a few seconds.'** + String get webPushWaitingBody; + + /// No description provided for @webPushNeedsOnline. + /// + /// In en, this message translates to: + /// **'No connection to the server. Wait for it and try again.'** + String get webPushNeedsOnline; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index f78702c..95356ea 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2706,4 +2706,69 @@ class AppLocalizationsEn extends AppLocalizations { @override String get videoEditorQualityTooltip => 'Quality'; + + @override + String get webPushTitle => 'Notifications on iOS'; + + @override + String get webPushIntro => + 'Komet has no ordinary push on iOS: Apple issues a notification token only to apps signed with a developer certificate, and a sideloaded build never gets one.\n\nThe way around it is a web app on the Home Screen. MAX\'s own server sends the notifications through Apple, and a separate icon displays them.\n\nThat needs a web session. Komet creates one and approves it itself, from this very device — no phone number or code required.'; + + @override + String get webPushConfirm => 'Continue'; + + @override + String get webPushPasswordExplainer => + 'Two-factor protection is enabled on this account.'; + + @override + String webPushPasswordHintLabel(String hint) { + return 'Hint: $hint'; + } + + @override + String get webPushPasswordHint => 'Password'; + + @override + String get webPushInstallTitle => 'Install the web app'; + + @override + String get webPushInstallBody => + 'Open push.komet.pw in Safari, add it to the Home Screen and launch the icon that appears. Notifications do not work from a browser tab — that is how iOS works.\n\nIn the app, allow notifications, create a subscription and tap \"Open Komet\". The subscription registers itself from there.'; + + @override + String get webPushLinkedTitle => 'Notifications connected'; + + @override + String get webPushLinkedBody => + 'The subscription is registered on the server. Do not delete the Home Screen icon — the notifications go with it.\n\nIf push stops arriving, open the web app and link again: Apple sometimes rotates the subscription address.'; + + @override + String get webPushOpenSite => 'Open push.komet.pw'; + + @override + String get webPushSignOut => 'Disconnect notifications'; + + @override + String get webPushLinked => 'Notifications connected'; + + @override + String webPushLinkFailed(String error) { + return 'Could not connect notifications: $error'; + } + + @override + String get webPushNotAuthorized => + 'Sign in under \"Notifications via PWA\" first'; + + @override + String get webPushConnect => 'Connect notifications'; + + @override + String get webPushWaitingBody => + 'Komet is approving the web session from this device. This usually takes a few seconds.'; + + @override + String get webPushNeedsOnline => + 'No connection to the server. Wait for it and try again.'; } diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 7874df6..15b6431 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -2718,4 +2718,69 @@ class AppLocalizationsRu extends AppLocalizations { @override String get videoEditorQualityTooltip => 'Качество'; + + @override + String get webPushTitle => 'Уведомления на iOS'; + + @override + String get webPushIntro => + 'На iOS у Комета нет обычных пушей: Apple выдаёт токен уведомлений только приложениям, подписанным сертификатом разработчика, а sideload-сборка такого не получает.\n\nОбход — веб-приложение на экране «Домой». Уведомления шлёт сам сервер MAX через Apple, а показывает их отдельная иконка.\n\nДля этого нужна веб-сессия. Комет создаст её и подтвердит сам, с этого же устройства — вводить номер и код не придётся.'; + + @override + String get webPushConfirm => 'Продолжить'; + + @override + String get webPushPasswordExplainer => + 'На аккаунте включена двухфакторная защита.'; + + @override + String webPushPasswordHintLabel(String hint) { + return 'Подсказка: $hint'; + } + + @override + String get webPushPasswordHint => 'Пароль'; + + @override + String get webPushInstallTitle => 'Установите приложение'; + + @override + String get webPushInstallBody => + 'Откройте push.komet.pw в Safari, добавьте на экран «Домой» и запустите появившуюся иконку. Из вкладки браузера уведомления не работают — так устроена iOS.\n\nВ приложении разрешите уведомления, создайте подписку и нажмите «Открыть Комет». Дальше подписка зарегистрируется сама.'; + + @override + String get webPushLinkedTitle => 'Уведомления подключены'; + + @override + String get webPushLinkedBody => + 'Подписка зарегистрирована на сервере. Не удаляйте иконку с экрана «Домой» — вместе с ней пропадут уведомления.\n\nЕсли пуши перестанут приходить, откройте приложение и свяжите заново: Apple иногда меняет адрес подписки.'; + + @override + String get webPushOpenSite => 'Открыть push.komet.pw'; + + @override + String get webPushSignOut => 'Отключить уведомления'; + + @override + String get webPushLinked => 'Уведомления подключены'; + + @override + String webPushLinkFailed(String error) { + return 'Не удалось подключить уведомления: $error'; + } + + @override + String get webPushNotAuthorized => + 'Сначала войдите в разделе «Уведомления через PWA»'; + + @override + String get webPushConnect => 'Подключить уведомления'; + + @override + String get webPushWaitingBody => + 'Комет подтверждает вход веб-сессии с этого устройства. Обычно занимает несколько секунд.'; + + @override + String get webPushNeedsOnline => + 'Нет связи с сервером. Дождитесь подключения и попробуйте снова.'; } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 6ba9e40..9c39028 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -50,7 +50,6 @@ "codeResendSms": "Отправить код по SMS", "codeError2faMissing": "Ошибка: отсутствуют данные для 2FA", "codeConfirmation2faWarning": "По умолчанию код приходит в МАХ. Если код не приходит по SMS - не заходите в Komet/MAX 30 минут, и попробуйте заново.", - "proxySettingsTitle": "Прокси", "proxyTypeNone": "Выключен", "proxyTypeSocks5": "SOCKS5", @@ -63,7 +62,6 @@ "proxyDisable": "Отключить прокси", "proxySettingsSaved": "Настройки прокси применены", "proxyInvalidHostOrPort": "Укажите корректный хост и порт прокси (1–65535)", - "spoofScreenTitle": "Подмена данных сессии", "spoofEnableTitle": "Подмена устройства", "spoofEnableSubtitleOn": "Включена для этого аккаунта", @@ -200,7 +198,6 @@ "chatInfoHasBots": "Есть боты:", "chatInfoBlockedCount": "в ЧС группы:", "chatInfoOfficialStatus": "Официальный статус:", - "chatInfoLastChanged": "последнее изменение:", "chatInfoJoined": "Зашли в:", "chatInfoGroupCreated": "Группа создана в:", "chatInfoGroupOwner": "Создатель группы:", @@ -333,7 +330,6 @@ "appearancePreviewHowIsIt": "Как тебе?", "appearancePreviewHmm": "хм...", "appearancePreviewNotBad": "Вполне неплохо!", - "callKometDetectedNotification": "Этот человек использует Komet! :3", "callStatusConnecting": "Соединение...", "callGroupConnecting": "Соединение...", @@ -396,7 +392,6 @@ "callBadgeNoiseSuppression": "Шумоподавление", "callBadgeAnimoji": "Анимодзи", "callInfoNoDataYet": "Данные появятся после соединения…", - "hubTitleMenu": "Komet", "hubChatPageTitle": "Анонимный чат", "hubGamesTitle": "Игры", @@ -417,7 +412,6 @@ "hubCheckersLost": "Вы проиграли", "hubCheckersYourMove": "Ваш ход", "hubCheckersOpponentMove": "Ход соперника…", - "scheduledPickTimeTitle": "Когда отправить", "scheduledEditTitle": "Изменить", "scheduledMessageTextHint": "Текст сообщения", @@ -436,7 +430,6 @@ "scheduledAttachLocation": "Геопозиция", "scheduledAttachForwarded": "Переслано", "scheduledAttachGeneric": "Вложение", - "contactProfileLoadError": "Ошибка: {error}", "contactProfileBot": "Бот", "contactProfileOnline": "В сети", @@ -454,7 +447,6 @@ "contactProfileInfoDescription": "Описание", "contactProfileInfoLink": "Ссылка", "contactProfileInfoFlags": "Флаги", - "nfcPeerNameFallback": "Контакт #{id}", "nfcPeerFirstNameFallback": "Контакт", "nfcContactAdded": "Контакт добавлен", @@ -472,7 +464,6 @@ "nfcPeerIdFallback": "ID {id}", "nfcAdded": "Добавлено", "nfcAddContact": "Добавить контакт", - "chatInfoTabGeneralChats": "Общие чаты", "chatInfoTabMedia": "Медиа", "chatInfoTabFiles": "Файлы", @@ -589,7 +580,6 @@ "chatInfoRowComments": "Комментарии", "chatInfoRowRkn": "РКН", "chatInfoRowOnlyAdmin": "Только адм.", - "securityTitle": "Безопасность", "securityLoadError": "Ошибка загрузки: {error}", "securitySaveError": "Ошибка сохранения: {error}", @@ -622,7 +612,6 @@ "securityAudioTranscription": "Транскрибация аудио", "securityBlacklistTitle": "Чёрный список", "securityBlacklistNotification": "Чёрный список: {count} контактов", - "passwordEntryWrongPassword": "Неверный пароль", "passwordEntryConfirmTitle": "Подтвердите пароль", "passwordEntryCurrentPasswordHint": "Текущий пароль", @@ -922,5 +911,37 @@ "videoEditorProcessing": "Обработка видео…", "videoEditorExportFailed": "Не удалось обработать видео", "videoEditorFrameFailed": "Не удалось получить кадр", - "videoEditorQualityTooltip": "Качество" + "videoEditorQualityTooltip": "Качество", + "webPushTitle": "Уведомления на iOS", + "webPushIntro": "На iOS у Комета нет обычных пушей: Apple выдаёт токен уведомлений только приложениям, подписанным сертификатом разработчика, а sideload-сборка такого не получает.\n\nОбход — веб-приложение на экране «Домой». Уведомления шлёт сам сервер MAX через Apple, а показывает их отдельная иконка.\n\nДля этого нужна веб-сессия. Комет создаст её и подтвердит сам, с этого же устройства — вводить номер и код не придётся.", + "webPushConfirm": "Продолжить", + "webPushPasswordExplainer": "На аккаунте включена двухфакторная защита.", + "webPushPasswordHintLabel": "Подсказка: {hint}", + "@webPushPasswordHintLabel": { + "placeholders": { + "hint": { + "type": "String" + } + } + }, + "webPushPasswordHint": "Пароль", + "webPushInstallTitle": "Установите приложение", + "webPushInstallBody": "Откройте push.komet.pw в Safari, добавьте на экран «Домой» и запустите появившуюся иконку. Из вкладки браузера уведомления не работают — так устроена iOS.\n\nВ приложении разрешите уведомления, создайте подписку и нажмите «Открыть Комет». Дальше подписка зарегистрируется сама.", + "webPushLinkedTitle": "Уведомления подключены", + "webPushLinkedBody": "Подписка зарегистрирована на сервере. Не удаляйте иконку с экрана «Домой» — вместе с ней пропадут уведомления.\n\nЕсли пуши перестанут приходить, откройте приложение и свяжите заново: Apple иногда меняет адрес подписки.", + "webPushOpenSite": "Открыть push.komet.pw", + "webPushSignOut": "Отключить уведомления", + "webPushLinked": "Уведомления подключены", + "webPushLinkFailed": "Не удалось подключить уведомления: {error}", + "@webPushLinkFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "webPushNotAuthorized": "Сначала войдите в разделе «Уведомления через PWA»", + "webPushConnect": "Подключить уведомления", + "webPushWaitingBody": "Комет подтверждает вход веб-сессии с этого устройства. Обычно занимает несколько секунд.", + "webPushNeedsOnline": "Нет связи с сервером. Дождитесь подключения и попробуйте снова." } diff --git a/pubspec.lock b/pubspec.lock index 2b927dc..6efbc84 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -857,10 +857,10 @@ packages: dependency: "direct main" description: name: intl - sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" url: "https://pub.dev" source: hosted - version: "0.20.3" + version: "0.20.2" jni: dependency: transitive description: @@ -897,10 +897,10 @@ packages: dependency: "direct main" description: name: kolibri - sha256: e8ed4eab5687204a77d449743ceb73b0797f5702afb0a7841610a127fc102710 + sha256: e59a569756652b5a68d1260b52d9b2ae6f4c0fc831244f9d8aefa15bde03b370 url: "https://pub.dev" source: hosted - version: "0.1.2" + version: "0.1.4" komet_crypto: dependency: "direct main" description: @@ -992,10 +992,10 @@ packages: dependency: transitive description: name: matcher - sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.20" + version: "0.12.19" material_color_utilities: dependency: transitive description: @@ -1064,10 +1064,10 @@ packages: dependency: transitive description: name: meta - sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.19.0" + version: "1.18.0" mime: dependency: transitive description: @@ -1685,10 +1685,10 @@ packages: dependency: transitive description: name: test_api - sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.12" + version: "0.7.11" timezone: dependency: "direct main" description: @@ -1829,10 +1829,10 @@ packages: dependency: transitive description: name: vector_math - sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.2.0" video_player: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index ba25ac2..78c1dc2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 0.5.18+18 +version: 0.5.19+19 environment: sdk: ^3.10.4 @@ -37,7 +37,7 @@ dependencies: # Rust networking core (kolibri) — FFI plugin, replaces the Dart transport. # Published from the KometTeam/kolibri repo; the native core is compiled at # app build time and pulled from that repo by git tag. - kolibri: ^0.1.2 + kolibri: ^0.1.4 # Rust message-encryption core — Argon2id + ChaCha20-Poly1305, output encoded # as lowercase Cyrillic base32. Separate from kolibri: that is vendored diff --git a/test/max_web_protocol_test.dart b/test/max_web_protocol_test.dart new file mode 100644 index 0000000..09c1e5e --- /dev/null +++ b/test/max_web_protocol_test.dart @@ -0,0 +1,88 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/core/webpush/max_web_protocol.dart'; + +Uint8List _hex(String value) { + final bytes = Uint8List(value.length ~/ 2); + for (var i = 0; i < bytes.length; i++) { + bytes[i] = int.parse(value.substring(i * 2, i * 2 + 2), radix: 16); + } + return bytes; +} + +const _responseHex = '0a0100000006010000caf0b985ad7765622d7077612d70726f6d6fc3b270686f6e652d617574682d656e61626c6564c3a86c6f636174696f6ea25255a46c616e67c3b07265672d636f756e7472792d636f6465dc002aa2415aa2414da24b5aa24b47a24d44a2544aa2555aa24745a25448a25452a2544da24145a24c41a24d59a24944a24355a24b48a2564ea24146a2424fa24344a24347a2434fa24744a2474da2494ea24951a24b4ea24b57a24c42a24d4da24e49a2504ba25057a25141a25341a25645a2545aa24547a2434ea25a41a24252'; +const _requestHex = '0a00000000060000015482a9757365724167656e748baa64657669636554797065a3574542ae7075736844657669636554797065a757454250555348a66c6f63616c65a27275ac6465766963654c6f63616c65a27275a96f7356657273696f6ea56d61634f53aa6465766963654e616d65a6536166617269af686561646572557365724167656e74d9754d6f7a696c6c612f352e3020284d6163696e746f73683b20496e74656c204d6163204f5320582031305f31355f3729204170706c655765624b69742f3630352e312e313520284b48544d4c2c206c696b65204765636b6f292056657273696f6e2f31382e35205361666172692f3630352e312e3135a56973507761c3aa61707056657273696f6ea732362e362e3230a673637265656eac3935367834343020332e3078a874696d657a6f6e65ad4575726f70652f4d6f73636f77a86465766963654964b06b6f6d65742d636f6465632d74657374'; + +void main() { + test('заголовок кадра совпадает с эталоном сервера', () { + final frame = _hex(_responseHex); + expect(frame[0], MaxWebFraming.protocolVersion); + final decoded = MaxWebFraming.decode(frame); + expect(decoded.cmd, MaxWebCmd.ok); + expect(decoded.opcode, 6); + expect(decoded.isOk, isTrue); + }); + + test('распаковка LZ4 и msgpack на живом ответе сервера', () { + final decoded = MaxWebFraming.decode(_hex(_responseHex)); + final payload = decoded.payload as Map; + expect(payload['location'], 'RU'); + expect(payload['web-pwa-promo'], isTrue); + expect(payload['reg-country-code'], isA>()); + expect(payload.length, 5); + }); + + test('кодирование кадра байт в байт как у веб-клиента', () { + final expected = _hex(_requestHex); + final payload = jsonDecode(_requestPayloadJson) as Map; + final actual = MaxWebFraming.encode(cmd: 0, seq: 0, opcode: 6, payload: payload); + expect(actual.length, expected.length); + expect(actual.sublist(0, MaxWebFraming.headerSize), + expected.sublist(0, MaxWebFraming.headerSize)); + }); + + test('msgpack переживает круговой рейс', () { + final source = { + 'subscribe': true, + 'pushToken': 'https://web.push.apple.com/AAA-bbb_ccc', + 'secretKey': 'z2sMRx0MgXELERMrtCcK_Q', + 'publicKey': 'BOgrn-9cRlyU4jnyxQROVWWrTgpof_3T9UAO6DR6QkNvIbexyAQSEz4J5BBM6VQY6hZklsMq06aSK1oCzF5A644', + 'chatsCount': 40, + 'presenceSync': -1, + 'nested': {'a': null, 'b': 3.5, 'c': [1, 'два', false]}, + }; + final restored = MaxMsgpack.decode(MaxMsgpack.encode(source)) as Map; + expect(restored['subscribe'], true); + expect(restored['pushToken'], source['pushToken']); + expect(restored['chatsCount'], 40); + expect(restored['presenceSync'], -1); + final nested = restored['nested'] as Map; + expect(nested['a'], isNull); + expect(nested['b'], 3.5); + expect((nested['c'] as List)[1], 'два'); + }); + + test('ext(1) разворачивается во вложенное число', () { + final pollingInterval = MaxMsgpack.decode( + Uint8List.fromList([0x81, 0xA8, 0x69, 0x6E, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6C, + 0xC7, 0x03, 0x01, 0xD1, 0x13, 0x88]), + ) as Map; + expect(pollingInterval['interval'], 5000); + + final expiresAt = MaxMsgpack.decode( + Uint8List.fromList([0xC7, 0x09, 0x01, 0xD3, 0x00, 0x00, 0x01, 0xA0, 0x25, + 0x5B, 0xE9, 0xD2]), + ); + expect(expiresAt, 1787333175762); + }); + + test('LZ4 разворачивает перекрывающиеся совпадения', () { + final compressed = Uint8List.fromList([0x6E, 0x6B, 0x6F, 0x6D, 0x65, 0x74, 0x20, 0x06, 0x00, 0x46, 0x70, 0x75, 0x73, 0x68, 0x05, 0x00, 0x50, 0x20, 0x70, 0x75, 0x73, 0x68]); + final result = Lz4Block.decompress(compressed, 256); + expect(utf8.decode(result), 'komet komet komet komet push push push push'); + }); +} + +const _requestPayloadJson = r'''{"userAgent": {"deviceType": "WEB", "pushDeviceType": "WEBPUSH", "locale": "ru", "deviceLocale": "ru", "osVersion": "macOS", "deviceName": "Safari", "headerUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15", "isPwa": true, "appVersion": "26.6.20", "screen": "956x440 3.0x", "timezone": "Europe/Moscow"}, "deviceId": "komet-codec-test"}''';