feat: уведомления на iOS через PWA
This commit is contained in:
@@ -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<String, Object?> toHandshakeUserAgent() => <String, Object?>{
|
||||
'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<dynamic>? _subscription;
|
||||
final Map<int, Completer<Object?>> _pending = <int, Completer<Object?>>{};
|
||||
int _seq = 0;
|
||||
bool _closed = false;
|
||||
|
||||
MaxWebSocketSession({required this.device});
|
||||
|
||||
Future<Object?> connect() async {
|
||||
final socket = await WebSocket.connect(
|
||||
endpoint,
|
||||
headers: <String, Object>{
|
||||
'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, <String, Object?>{
|
||||
'userAgent': device.toHandshakeUserAgent(),
|
||||
'deviceId': device.deviceId,
|
||||
});
|
||||
}
|
||||
|
||||
Future<Object?> request(int opcode, Object? payload) {
|
||||
final socket = _socket;
|
||||
if (socket == null || _closed) {
|
||||
return Future<Object?>.error(
|
||||
const MaxWebException('сессия не подключена'),
|
||||
);
|
||||
}
|
||||
|
||||
_seq += 1;
|
||||
final seq = _seq;
|
||||
final completer = Completer<Object?>();
|
||||
_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<int>) 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<void> close() async {
|
||||
if (_closed) return;
|
||||
_closed = true;
|
||||
_failAll(const MaxWebException('сессия закрыта'));
|
||||
await _subscription?.cancel();
|
||||
_subscription = null;
|
||||
await _socket?.close();
|
||||
_socket = null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user