feat(links): поддержка max.ru-ссылок и komet:// диплинков
- резолв max.ru/{join,joincall,u,c,:auth,<channel>}
- открытие чатов/профилей/звонков и подтверждение веб-входа в приложении
- внешние диплинки: intent-filter (Android), URL-схемы (iOS/macOS), komet://-регистрация в рантайме (Windows/Linux)
- fix(calls): не слать opcode 79 до login (жижа я тебя выебу)
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:app_links/app_links.dart';
|
||||
|
||||
import '../../backend/api.dart';
|
||||
import '../../frontend/widgets/max_link_handler.dart';
|
||||
import '../../main.dart';
|
||||
import 'desktop_url_scheme.dart';
|
||||
|
||||
class DeepLinkService {
|
||||
DeepLinkService._();
|
||||
|
||||
static final DeepLinkService instance = DeepLinkService._();
|
||||
|
||||
final AppLinks _appLinks = AppLinks();
|
||||
StreamSubscription<Uri>? _sub;
|
||||
StreamSubscription<SessionState>? _stateSub;
|
||||
String? _pending;
|
||||
bool _ready = false;
|
||||
bool _started = false;
|
||||
|
||||
Future<void> init() async {
|
||||
if (_started) return;
|
||||
_started = true;
|
||||
|
||||
await DesktopUrlScheme.register();
|
||||
|
||||
_stateSub = api.stateStream.listen((state) {
|
||||
if (state == SessionState.online) _flushPending();
|
||||
});
|
||||
|
||||
_sub = _appLinks.uriLinkStream.listen(_onUri);
|
||||
try {
|
||||
final initial = await _appLinks.getInitialLink();
|
||||
if (initial != null) _onUri(initial);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void markReady() {
|
||||
_ready = true;
|
||||
_flushPending();
|
||||
}
|
||||
|
||||
void _onUri(Uri uri) {
|
||||
final url = _normalize(uri);
|
||||
if (url == null) return;
|
||||
_pending = url;
|
||||
_flushPending();
|
||||
}
|
||||
|
||||
void _flushPending() {
|
||||
final pending = _pending;
|
||||
if (pending == null || !_ready) return;
|
||||
if (api.state != SessionState.online) return;
|
||||
final context = KometApp.navigatorKey.currentContext;
|
||||
if (context == null) return;
|
||||
|
||||
_pending = null;
|
||||
tryHandleMaxLink(context, pending);
|
||||
}
|
||||
|
||||
String? _normalize(Uri uri) {
|
||||
final scheme = uri.scheme.toLowerCase();
|
||||
|
||||
if (scheme == 'https' || scheme == 'http') {
|
||||
final host = uri.host.toLowerCase();
|
||||
if (host == 'max.ru' || host == 'www.max.ru') return uri.toString();
|
||||
return null;
|
||||
}
|
||||
|
||||
if (scheme == 'komet' || scheme == 'max') {
|
||||
final segments = <String>[
|
||||
if (uri.host.isNotEmpty && uri.host.toLowerCase() != 'max.ru') uri.host,
|
||||
...uri.pathSegments,
|
||||
].where((s) => s.isNotEmpty).toList();
|
||||
if (segments.isEmpty) return null;
|
||||
final query = uri.query.isNotEmpty ? '?${uri.query}' : '';
|
||||
return 'https://max.ru/${segments.join('/')}$query';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_sub?.cancel();
|
||||
_sub = null;
|
||||
_stateSub?.cancel();
|
||||
_stateSub = null;
|
||||
_started = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import 'dart:io';
|
||||
|
||||
import '../utils/logger.dart';
|
||||
|
||||
const List<String> _schemes = ['komet', 'max'];
|
||||
|
||||
abstract class DesktopUrlScheme {
|
||||
static Future<void> register() async {
|
||||
try {
|
||||
if (Platform.isWindows) {
|
||||
await _registerWindows();
|
||||
} else if (Platform.isLinux) {
|
||||
await _registerLinux();
|
||||
}
|
||||
} catch (e) {
|
||||
logger.w('DesktopUrlScheme: регистрация не удалась: $e');
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> _registerWindows() async {
|
||||
final exe = Platform.resolvedExecutable;
|
||||
for (final scheme in _schemes) {
|
||||
final capitalized = '${scheme[0].toUpperCase()}${scheme.substring(1)}';
|
||||
final base = 'HKCU\\Software\\Classes\\$scheme';
|
||||
|
||||
await _run('reg', ['add', base, '/ve', '/d', 'URL:$capitalized', '/f']);
|
||||
await _run('reg', ['add', base, '/v', 'URL Protocol', '/d', '', '/f']);
|
||||
await _run('reg', [
|
||||
'add',
|
||||
'$base\\shell\\open\\command',
|
||||
'/ve',
|
||||
'/d',
|
||||
'"$exe" "%1"',
|
||||
'/f',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> _registerLinux() async {
|
||||
final home = Platform.environment['HOME'];
|
||||
if (home == null || home.isEmpty) return;
|
||||
|
||||
final exe = Platform.resolvedExecutable;
|
||||
final appsDir = Directory('$home/.local/share/applications');
|
||||
await appsDir.create(recursive: true);
|
||||
|
||||
const fileName = 'komet-url-handler.desktop';
|
||||
final mimeTypes = _schemes.map((s) => 'x-scheme-handler/$s').join(';');
|
||||
final desktop = '[Desktop Entry]\n'
|
||||
'Type=Application\n'
|
||||
'Name=Komet\n'
|
||||
'Exec="$exe" %u\n'
|
||||
'Terminal=false\n'
|
||||
'NoDisplay=true\n'
|
||||
'MimeType=$mimeTypes;\n';
|
||||
|
||||
final file = File('${appsDir.path}/$fileName');
|
||||
if (!file.existsSync() || await file.readAsString() != desktop) {
|
||||
await file.writeAsString(desktop);
|
||||
}
|
||||
|
||||
for (final scheme in _schemes) {
|
||||
await _run('xdg-mime', ['default', fileName, 'x-scheme-handler/$scheme']);
|
||||
}
|
||||
await _run('update-desktop-database', [appsDir.path]);
|
||||
}
|
||||
|
||||
static Future<void> _run(String executable, List<String> args) async {
|
||||
try {
|
||||
await Process.run(executable, args);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
enum MaxLinkKind { call, invite, user, content, public, auth }
|
||||
|
||||
class MaxLink {
|
||||
final MaxLinkKind kind;
|
||||
final String url;
|
||||
|
||||
const MaxLink(this.kind, this.url);
|
||||
|
||||
static final RegExp _host = RegExp(
|
||||
r'^https?://(?:www\.)?max\.ru/(.+)$',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
static final RegExp _segment = RegExp(r'^[A-Za-z0-9_]+$');
|
||||
|
||||
static const Set<String> _reserved = {
|
||||
'join',
|
||||
'joincall',
|
||||
'u',
|
||||
'c',
|
||||
'login',
|
||||
'ps',
|
||||
'tos',
|
||||
'privacy',
|
||||
'about',
|
||||
'help',
|
||||
};
|
||||
|
||||
static bool isMaxLink(String url) => parse(url) != null;
|
||||
|
||||
static MaxLink? parse(String input) {
|
||||
final url = input.trim();
|
||||
final match = _host.firstMatch(url);
|
||||
if (match == null) return null;
|
||||
|
||||
final path = match.group(1)!.split('?').first.split('#').first;
|
||||
final segments =
|
||||
path.split('/').where((s) => s.isNotEmpty).toList(growable: false);
|
||||
if (segments.isEmpty) return null;
|
||||
|
||||
switch (segments.first.toLowerCase()) {
|
||||
case ':auth':
|
||||
return segments.length >= 2 ? MaxLink(MaxLinkKind.auth, url) : null;
|
||||
case 'joincall':
|
||||
return segments.length >= 2 ? MaxLink(MaxLinkKind.call, url) : null;
|
||||
case 'join':
|
||||
return segments.length >= 2 ? MaxLink(MaxLinkKind.invite, url) : null;
|
||||
case 'u':
|
||||
return segments.length >= 2 ? MaxLink(MaxLinkKind.user, url) : null;
|
||||
case 'c':
|
||||
return segments.length >= 3 ? MaxLink(MaxLinkKind.content, url) : null;
|
||||
}
|
||||
|
||||
if (_reserved.contains(segments.first.toLowerCase())) return null;
|
||||
if (!_segment.hasMatch(segments.first)) return null;
|
||||
return MaxLink(MaxLinkKind.public, url);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user