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:
klockky
2026-06-23 14:18:51 +03:00
parent 7f434dfd81
commit 4277b0a20d
16 changed files with 646 additions and 112 deletions
+91
View File
@@ -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;
}
}
+73
View File
@@ -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 (_) {}
}
}
+58
View File
@@ -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);
}
}
+2 -2
View File
@@ -1,11 +1,11 @@
import 'package:flutter/widgets.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../frontend/widgets/call_link_handler.dart';
import '../../frontend/widgets/custom_notification.dart';
import '../../frontend/widgets/max_link_handler.dart';
Future<void> openExternalUrl(BuildContext context, String url) async {
if (await tryHandleCallLink(context, url)) return;
if (await tryHandleMaxLink(context, url)) return;
if (!context.mounted) return;
final uri = Uri.tryParse(url);