fix(relay): secure reconnect and room ownership
This commit is contained in:
@@ -58,6 +58,7 @@ jobs:
|
|||||||
python3 scripts/check_workflow_action_pins.py
|
python3 scripts/check_workflow_action_pins.py
|
||||||
python3 scripts/test_check_workflow_action_pins.py
|
python3 scripts/test_check_workflow_action_pins.py
|
||||||
python3 scripts/test_check_codegen.py
|
python3 scripts/test_check_codegen.py
|
||||||
|
python3 scripts/test_generate_relay_protocol.py
|
||||||
python3 scripts/test_format_native.py
|
python3 scripts/test_format_native.py
|
||||||
python3 scripts/test_run_maestro.py
|
python3 scripts/test_run_maestro.py
|
||||||
python3 scripts/check_update_packages_workflow.py
|
python3 scripts/check_update_packages_workflow.py
|
||||||
|
|||||||
@@ -180,6 +180,7 @@
|
|||||||
"watchTogetherRelay": "Релей сървър за гледане заедно",
|
"watchTogetherRelay": "Релей сървър за гледане заедно",
|
||||||
"watchTogetherRelayDescription": "Задай собствен релей сървър. Всички трябва да използват същия сървър.",
|
"watchTogetherRelayDescription": "Задай собствен релей сървър. Всички трябва да използват същия сървър.",
|
||||||
"watchTogetherRelayHint": "https://my-relay.example.com",
|
"watchTogetherRelayHint": "https://my-relay.example.com",
|
||||||
|
"watchTogetherRelayInvalid": "",
|
||||||
"crashReporting": "Докладване на сривове",
|
"crashReporting": "Докладване на сривове",
|
||||||
"crashReportingDescription": "Изпращай доклади за сривове, за да помогнеш за подобряване на приложението",
|
"crashReportingDescription": "Изпращай доклади за сривове, за да помогнеш за подобряване на приложението",
|
||||||
"debugLogging": "Логове за отстраняване на грешки",
|
"debugLogging": "Логове за отстраняване на грешки",
|
||||||
|
|||||||
@@ -180,6 +180,7 @@
|
|||||||
"watchTogetherRelay": "Watch Together-relay",
|
"watchTogetherRelay": "Watch Together-relay",
|
||||||
"watchTogetherRelayDescription": "Angiv en brugerdefineret relay. Alle skal bruge samme server.",
|
"watchTogetherRelayDescription": "Angiv en brugerdefineret relay. Alle skal bruge samme server.",
|
||||||
"watchTogetherRelayHint": "https://min-relay.eksempel.dk",
|
"watchTogetherRelayHint": "https://min-relay.eksempel.dk",
|
||||||
|
"watchTogetherRelayInvalid": "",
|
||||||
"crashReporting": "Fejlrapportering",
|
"crashReporting": "Fejlrapportering",
|
||||||
"crashReportingDescription": "Send fejlrapporter for at hjælpe med at forbedre appen",
|
"crashReportingDescription": "Send fejlrapporter for at hjælpe med at forbedre appen",
|
||||||
"debugLogging": "Fejlfindingslogning",
|
"debugLogging": "Fejlfindingslogning",
|
||||||
|
|||||||
@@ -180,6 +180,7 @@
|
|||||||
"watchTogetherRelay": "Gemeinsam Schauen Relay",
|
"watchTogetherRelay": "Gemeinsam Schauen Relay",
|
||||||
"watchTogetherRelayDescription": "Eigenes Relay festlegen. Alle müssen denselben Server verwenden.",
|
"watchTogetherRelayDescription": "Eigenes Relay festlegen. Alle müssen denselben Server verwenden.",
|
||||||
"watchTogetherRelayHint": "https://mein-relay.beispiel.de",
|
"watchTogetherRelayHint": "https://mein-relay.beispiel.de",
|
||||||
|
"watchTogetherRelayInvalid": "",
|
||||||
"crashReporting": "Absturzberichte",
|
"crashReporting": "Absturzberichte",
|
||||||
"crashReportingDescription": "Absturzberichte senden, um die App zu verbessern",
|
"crashReportingDescription": "Absturzberichte senden, um die App zu verbessern",
|
||||||
"debugLogging": "Debug-Protokollierung",
|
"debugLogging": "Debug-Protokollierung",
|
||||||
|
|||||||
@@ -180,6 +180,7 @@
|
|||||||
"watchTogetherRelay": "Watch Together Relay",
|
"watchTogetherRelay": "Watch Together Relay",
|
||||||
"watchTogetherRelayDescription": "Set a custom relay. Everyone must use the same server.",
|
"watchTogetherRelayDescription": "Set a custom relay. Everyone must use the same server.",
|
||||||
"watchTogetherRelayHint": "https://my-relay.example.com",
|
"watchTogetherRelayHint": "https://my-relay.example.com",
|
||||||
|
"watchTogetherRelayInvalid": "Enter a valid HTTP or HTTPS relay base URL.",
|
||||||
"crashReporting": "Crash Reporting",
|
"crashReporting": "Crash Reporting",
|
||||||
"crashReportingDescription": "Send crash reports to help improve the app",
|
"crashReportingDescription": "Send crash reports to help improve the app",
|
||||||
"debugLogging": "Debug Logging",
|
"debugLogging": "Debug Logging",
|
||||||
|
|||||||
@@ -180,6 +180,7 @@
|
|||||||
"watchTogetherRelay": "Relay de Ver Juntos",
|
"watchTogetherRelay": "Relay de Ver Juntos",
|
||||||
"watchTogetherRelayDescription": "Define un relay personalizado. Todos deben usar el mismo servidor.",
|
"watchTogetherRelayDescription": "Define un relay personalizado. Todos deben usar el mismo servidor.",
|
||||||
"watchTogetherRelayHint": "https://mi-relay.ejemplo.com",
|
"watchTogetherRelayHint": "https://mi-relay.ejemplo.com",
|
||||||
|
"watchTogetherRelayInvalid": "",
|
||||||
"crashReporting": "Informes de Errores",
|
"crashReporting": "Informes de Errores",
|
||||||
"crashReportingDescription": "Enviar informes de errores para mejorar la aplicación",
|
"crashReportingDescription": "Enviar informes de errores para mejorar la aplicación",
|
||||||
"debugLogging": "Registro de Depuración",
|
"debugLogging": "Registro de Depuración",
|
||||||
|
|||||||
@@ -180,6 +180,7 @@
|
|||||||
"watchTogetherRelay": "Relais Regarder Ensemble",
|
"watchTogetherRelay": "Relais Regarder Ensemble",
|
||||||
"watchTogetherRelayDescription": "Définir un relay personnalisé. Tout le monde doit utiliser le même serveur.",
|
"watchTogetherRelayDescription": "Définir un relay personnalisé. Tout le monde doit utiliser le même serveur.",
|
||||||
"watchTogetherRelayHint": "https://mon-relais.exemple.fr",
|
"watchTogetherRelayHint": "https://mon-relais.exemple.fr",
|
||||||
|
"watchTogetherRelayInvalid": "",
|
||||||
"crashReporting": "Rapports de plantage",
|
"crashReporting": "Rapports de plantage",
|
||||||
"crashReportingDescription": "Envoyer des rapports de plantage pour améliorer l'application",
|
"crashReportingDescription": "Envoyer des rapports de plantage pour améliorer l'application",
|
||||||
"debugLogging": "Journalisation de débogage",
|
"debugLogging": "Journalisation de débogage",
|
||||||
|
|||||||
@@ -180,6 +180,7 @@
|
|||||||
"watchTogetherRelay": "Relay Guarda Insieme",
|
"watchTogetherRelay": "Relay Guarda Insieme",
|
||||||
"watchTogetherRelayDescription": "Imposta un relay personalizzato. Tutti devono usare lo stesso server.",
|
"watchTogetherRelayDescription": "Imposta un relay personalizzato. Tutti devono usare lo stesso server.",
|
||||||
"watchTogetherRelayHint": "https://mio-relay.esempio.it",
|
"watchTogetherRelayHint": "https://mio-relay.esempio.it",
|
||||||
|
"watchTogetherRelayInvalid": "",
|
||||||
"crashReporting": "Segnalazione errori",
|
"crashReporting": "Segnalazione errori",
|
||||||
"crashReportingDescription": "Invia segnalazioni di errori per migliorare l'app",
|
"crashReportingDescription": "Invia segnalazioni di errori per migliorare l'app",
|
||||||
"debugLogging": "Log di debug",
|
"debugLogging": "Log di debug",
|
||||||
|
|||||||
@@ -180,6 +180,7 @@
|
|||||||
"watchTogetherRelay": "一緒に視聴リレーサーバー",
|
"watchTogetherRelay": "一緒に視聴リレーサーバー",
|
||||||
"watchTogetherRelayDescription": "カスタムリレーを設定します。全員が同じサーバーを使う必要があります。",
|
"watchTogetherRelayDescription": "カスタムリレーを設定します。全員が同じサーバーを使う必要があります。",
|
||||||
"watchTogetherRelayHint": "https://my-relay.example.com",
|
"watchTogetherRelayHint": "https://my-relay.example.com",
|
||||||
|
"watchTogetherRelayInvalid": "",
|
||||||
"crashReporting": "クラッシュレポート",
|
"crashReporting": "クラッシュレポート",
|
||||||
"crashReportingDescription": "アプリの改善に役立つクラッシュレポートを送信",
|
"crashReportingDescription": "アプリの改善に役立つクラッシュレポートを送信",
|
||||||
"debugLogging": "デバッグログ",
|
"debugLogging": "デバッグログ",
|
||||||
|
|||||||
@@ -180,6 +180,7 @@
|
|||||||
"watchTogetherRelay": "함께 보기 릴레이",
|
"watchTogetherRelay": "함께 보기 릴레이",
|
||||||
"watchTogetherRelayDescription": "사용자 지정 릴레이를 설정합니다. 모두 같은 서버를 사용해야 합니다.",
|
"watchTogetherRelayDescription": "사용자 지정 릴레이를 설정합니다. 모두 같은 서버를 사용해야 합니다.",
|
||||||
"watchTogetherRelayHint": "https://my-relay.example.com",
|
"watchTogetherRelayHint": "https://my-relay.example.com",
|
||||||
|
"watchTogetherRelayInvalid": "",
|
||||||
"crashReporting": "충돌 보고",
|
"crashReporting": "충돌 보고",
|
||||||
"crashReportingDescription": "앱 개선을 위해 충돌 보고서 전송",
|
"crashReportingDescription": "앱 개선을 위해 충돌 보고서 전송",
|
||||||
"debugLogging": "디버그 로깅",
|
"debugLogging": "디버그 로깅",
|
||||||
|
|||||||
@@ -180,6 +180,7 @@
|
|||||||
"watchTogetherRelay": "Se Sammen-relay",
|
"watchTogetherRelay": "Se Sammen-relay",
|
||||||
"watchTogetherRelayDescription": "Angi en egendefinert relay. Alle må bruke samme server.",
|
"watchTogetherRelayDescription": "Angi en egendefinert relay. Alle må bruke samme server.",
|
||||||
"watchTogetherRelayHint": "https://min-relay.eksempel.no",
|
"watchTogetherRelayHint": "https://min-relay.eksempel.no",
|
||||||
|
"watchTogetherRelayInvalid": "",
|
||||||
"crashReporting": "Krasjrapportering",
|
"crashReporting": "Krasjrapportering",
|
||||||
"crashReportingDescription": "Send krasjrapporter for å hjelpe med å forbedre appen",
|
"crashReportingDescription": "Send krasjrapporter for å hjelpe med å forbedre appen",
|
||||||
"debugLogging": "Feilsøkingslogging",
|
"debugLogging": "Feilsøkingslogging",
|
||||||
|
|||||||
@@ -180,6 +180,7 @@
|
|||||||
"watchTogetherRelay": "Samen Kijken Relay",
|
"watchTogetherRelay": "Samen Kijken Relay",
|
||||||
"watchTogetherRelayDescription": "Stel een aangepaste relay in. Iedereen moet dezelfde server gebruiken.",
|
"watchTogetherRelayDescription": "Stel een aangepaste relay in. Iedereen moet dezelfde server gebruiken.",
|
||||||
"watchTogetherRelayHint": "https://mijn-relay.voorbeeld.nl",
|
"watchTogetherRelayHint": "https://mijn-relay.voorbeeld.nl",
|
||||||
|
"watchTogetherRelayInvalid": "",
|
||||||
"crashReporting": "Crashrapportage",
|
"crashReporting": "Crashrapportage",
|
||||||
"crashReportingDescription": "Crashrapporten verzenden om de app te verbeteren",
|
"crashReportingDescription": "Crashrapporten verzenden om de app te verbeteren",
|
||||||
"debugLogging": "Debug logging",
|
"debugLogging": "Debug logging",
|
||||||
|
|||||||
@@ -180,6 +180,7 @@
|
|||||||
"watchTogetherRelay": "Relay Oglądaj Razem",
|
"watchTogetherRelay": "Relay Oglądaj Razem",
|
||||||
"watchTogetherRelayDescription": "Ustaw własny relay. Wszyscy muszą używać tego samego serwera.",
|
"watchTogetherRelayDescription": "Ustaw własny relay. Wszyscy muszą używać tego samego serwera.",
|
||||||
"watchTogetherRelayHint": "https://moj-relay.przyklad.pl",
|
"watchTogetherRelayHint": "https://moj-relay.przyklad.pl",
|
||||||
|
"watchTogetherRelayInvalid": "",
|
||||||
"crashReporting": "Raportowanie błędów",
|
"crashReporting": "Raportowanie błędów",
|
||||||
"crashReportingDescription": "Wysyłaj raporty o błędach, aby pomóc ulepszyć aplikację",
|
"crashReportingDescription": "Wysyłaj raporty o błędach, aby pomóc ulepszyć aplikację",
|
||||||
"debugLogging": "Logowanie debugowania",
|
"debugLogging": "Logowanie debugowania",
|
||||||
|
|||||||
@@ -180,6 +180,7 @@
|
|||||||
"watchTogetherRelay": "Relay do Assistir Juntos",
|
"watchTogetherRelay": "Relay do Assistir Juntos",
|
||||||
"watchTogetherRelayDescription": "Defina um relay personalizado. Todos devem usar o mesmo servidor.",
|
"watchTogetherRelayDescription": "Defina um relay personalizado. Todos devem usar o mesmo servidor.",
|
||||||
"watchTogetherRelayHint": "https://meu-relay.exemplo.com.br",
|
"watchTogetherRelayHint": "https://meu-relay.exemplo.com.br",
|
||||||
|
"watchTogetherRelayInvalid": "",
|
||||||
"crashReporting": "Relatório de Erros",
|
"crashReporting": "Relatório de Erros",
|
||||||
"crashReportingDescription": "Enviar relatórios de erros para ajudar a melhorar o app",
|
"crashReportingDescription": "Enviar relatórios de erros para ajudar a melhorar o app",
|
||||||
"debugLogging": "Log de Depuração",
|
"debugLogging": "Log de Depuração",
|
||||||
|
|||||||
@@ -180,6 +180,7 @@
|
|||||||
"watchTogetherRelay": "Relay совместного просмотра",
|
"watchTogetherRelay": "Relay совместного просмотра",
|
||||||
"watchTogetherRelayDescription": "Задайте свой relay. Все должны использовать один сервер.",
|
"watchTogetherRelayDescription": "Задайте свой relay. Все должны использовать один сервер.",
|
||||||
"watchTogetherRelayHint": "https://my-relay.example.com",
|
"watchTogetherRelayHint": "https://my-relay.example.com",
|
||||||
|
"watchTogetherRelayInvalid": "",
|
||||||
"crashReporting": "Отчёты об ошибках",
|
"crashReporting": "Отчёты об ошибках",
|
||||||
"crashReportingDescription": "Отправлять отчёты об ошибках для улучшения приложения",
|
"crashReportingDescription": "Отправлять отчёты об ошибках для улучшения приложения",
|
||||||
"debugLogging": "Журнал отладки",
|
"debugLogging": "Журнал отладки",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
/// To regenerate, run: `dart run slang`
|
/// To regenerate, run: `dart run slang`
|
||||||
///
|
///
|
||||||
/// Locales: 16
|
/// Locales: 16
|
||||||
/// Strings: 23011 (1438 per locale)
|
/// Strings: 23027 (1439 per locale)
|
||||||
|
|
||||||
// coverage:ignore-file
|
// coverage:ignore-file
|
||||||
// ignore_for_file: type=lint, unused_import
|
// ignore_for_file: type=lint, unused_import
|
||||||
|
|||||||
@@ -311,6 +311,7 @@ class _TranslationsSettingsBg extends TranslationsSettingsEn {
|
|||||||
@override String get watchTogetherRelay => 'Релей сървър за гледане заедно';
|
@override String get watchTogetherRelay => 'Релей сървър за гледане заедно';
|
||||||
@override String get watchTogetherRelayDescription => 'Задай собствен релей сървър. Всички трябва да използват същия сървър.';
|
@override String get watchTogetherRelayDescription => 'Задай собствен релей сървър. Всички трябва да използват същия сървър.';
|
||||||
@override String get watchTogetherRelayHint => 'https://my-relay.example.com';
|
@override String get watchTogetherRelayHint => 'https://my-relay.example.com';
|
||||||
|
@override String get watchTogetherRelayInvalid => '';
|
||||||
@override String get crashReporting => 'Докладване на сривове';
|
@override String get crashReporting => 'Докладване на сривове';
|
||||||
@override String get crashReportingDescription => 'Изпращай доклади за сривове, за да помогнеш за подобряване на приложението';
|
@override String get crashReportingDescription => 'Изпращай доклади за сривове, за да помогнеш за подобряване на приложението';
|
||||||
@override String get debugLogging => 'Логове за отстраняване на грешки';
|
@override String get debugLogging => 'Логове за отстраняване на грешки';
|
||||||
@@ -2303,6 +2304,7 @@ extension on TranslationsBg {
|
|||||||
'settings.watchTogetherRelay' => 'Релей сървър за гледане заедно',
|
'settings.watchTogetherRelay' => 'Релей сървър за гледане заедно',
|
||||||
'settings.watchTogetherRelayDescription' => 'Задай собствен релей сървър. Всички трябва да използват същия сървър.',
|
'settings.watchTogetherRelayDescription' => 'Задай собствен релей сървър. Всички трябва да използват същия сървър.',
|
||||||
'settings.watchTogetherRelayHint' => 'https://my-relay.example.com',
|
'settings.watchTogetherRelayHint' => 'https://my-relay.example.com',
|
||||||
|
'settings.watchTogetherRelayInvalid' => '',
|
||||||
'settings.crashReporting' => 'Докладване на сривове',
|
'settings.crashReporting' => 'Докладване на сривове',
|
||||||
'settings.crashReportingDescription' => 'Изпращай доклади за сривове, за да помогнеш за подобряване на приложението',
|
'settings.crashReportingDescription' => 'Изпращай доклади за сривове, за да помогнеш за подобряване на приложението',
|
||||||
'settings.debugLogging' => 'Логове за отстраняване на грешки',
|
'settings.debugLogging' => 'Логове за отстраняване на грешки',
|
||||||
@@ -2644,9 +2646,9 @@ extension on TranslationsBg {
|
|||||||
'messages.markedAsUnwatchedOffline' => 'Маркирано като негледано (ще се синхронизира, когато сте онлайн)',
|
'messages.markedAsUnwatchedOffline' => 'Маркирано като негледано (ще се синхронизира, когато сте онлайн)',
|
||||||
'messages.autoRemovedWatchedDownload' => ({required Object title}) => 'Автоматично премахнато: ${title}',
|
'messages.autoRemovedWatchedDownload' => ({required Object title}) => 'Автоматично премахнато: ${title}',
|
||||||
'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('bg'))(n, one: 'Автоматично премахнато ${n} гледано изтегляне', other: 'Автоматично премахнати ${n} гледани изтегляния', ),
|
'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('bg'))(n, one: 'Автоматично премахнато ${n} гледано изтегляне', other: 'Автоматично премахнати ${n} гледани изтегляния', ),
|
||||||
'messages.removedFromContinueWatching' => 'Премахнато от продължаване на гледането',
|
|
||||||
_ => null,
|
_ => null,
|
||||||
} ?? switch (path) {
|
} ?? switch (path) {
|
||||||
|
'messages.removedFromContinueWatching' => 'Премахнато от продължаване на гледането',
|
||||||
'messages.errorLoading' => ({required Object error}) => 'Грешка: ${error}',
|
'messages.errorLoading' => ({required Object error}) => 'Грешка: ${error}',
|
||||||
'messages.streamInterrupted' => 'Потокът прекъсна. Натиснете „Пусни“ или превъртете, за да опитате отново.',
|
'messages.streamInterrupted' => 'Потокът прекъсна. Натиснете „Пусни“ или превъртете, за да опитате отново.',
|
||||||
'messages.liveStreamInterrupted' => 'Потокът на живо прекъсна. Натиснете „Пусни“, за да опитате отново.',
|
'messages.liveStreamInterrupted' => 'Потокът на живо прекъсна. Натиснете „Пусни“, за да опитате отново.',
|
||||||
@@ -3158,9 +3160,9 @@ extension on TranslationsBg {
|
|||||||
'watchTogether.participantSeeked' => ({required Object name}) => '${name} превъртя',
|
'watchTogether.participantSeeked' => ({required Object name}) => '${name} превъртя',
|
||||||
'watchTogether.participantBuffering' => ({required Object name}) => '${name} буферира',
|
'watchTogether.participantBuffering' => ({required Object name}) => '${name} буферира',
|
||||||
'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} е с по-стара версия на приложението — синхронизирането не е налично',
|
'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} е с по-стара версия на приложението — синхронизирането не е налично',
|
||||||
'watchTogether.resumingWithout' => ({required Object name}) => 'Продължаване без ${name}',
|
|
||||||
_ => null,
|
_ => null,
|
||||||
} ?? switch (path) {
|
} ?? switch (path) {
|
||||||
|
'watchTogether.resumingWithout' => ({required Object name}) => 'Продължаване без ${name}',
|
||||||
'watchTogether.waitingForParticipants' => 'Изчакване другите да заредят...',
|
'watchTogether.waitingForParticipants' => 'Изчакване другите да заредят...',
|
||||||
'watchTogether.waitingForName' => ({required Object name}) => 'Изчакване на ${name}...',
|
'watchTogether.waitingForName' => ({required Object name}) => 'Изчакване на ${name}...',
|
||||||
'watchTogether.recentRooms' => 'Скорошни стаи',
|
'watchTogether.recentRooms' => 'Скорошни стаи',
|
||||||
|
|||||||
@@ -311,6 +311,7 @@ class _TranslationsSettingsDa extends TranslationsSettingsEn {
|
|||||||
@override String get watchTogetherRelay => 'Watch Together-relay';
|
@override String get watchTogetherRelay => 'Watch Together-relay';
|
||||||
@override String get watchTogetherRelayDescription => 'Angiv en brugerdefineret relay. Alle skal bruge samme server.';
|
@override String get watchTogetherRelayDescription => 'Angiv en brugerdefineret relay. Alle skal bruge samme server.';
|
||||||
@override String get watchTogetherRelayHint => 'https://min-relay.eksempel.dk';
|
@override String get watchTogetherRelayHint => 'https://min-relay.eksempel.dk';
|
||||||
|
@override String get watchTogetherRelayInvalid => '';
|
||||||
@override String get crashReporting => 'Fejlrapportering';
|
@override String get crashReporting => 'Fejlrapportering';
|
||||||
@override String get crashReportingDescription => 'Send fejlrapporter for at hjælpe med at forbedre appen';
|
@override String get crashReportingDescription => 'Send fejlrapporter for at hjælpe med at forbedre appen';
|
||||||
@override String get debugLogging => 'Fejlfindingslogning';
|
@override String get debugLogging => 'Fejlfindingslogning';
|
||||||
@@ -2303,6 +2304,7 @@ extension on TranslationsDa {
|
|||||||
'settings.watchTogetherRelay' => 'Watch Together-relay',
|
'settings.watchTogetherRelay' => 'Watch Together-relay',
|
||||||
'settings.watchTogetherRelayDescription' => 'Angiv en brugerdefineret relay. Alle skal bruge samme server.',
|
'settings.watchTogetherRelayDescription' => 'Angiv en brugerdefineret relay. Alle skal bruge samme server.',
|
||||||
'settings.watchTogetherRelayHint' => 'https://min-relay.eksempel.dk',
|
'settings.watchTogetherRelayHint' => 'https://min-relay.eksempel.dk',
|
||||||
|
'settings.watchTogetherRelayInvalid' => '',
|
||||||
'settings.crashReporting' => 'Fejlrapportering',
|
'settings.crashReporting' => 'Fejlrapportering',
|
||||||
'settings.crashReportingDescription' => 'Send fejlrapporter for at hjælpe med at forbedre appen',
|
'settings.crashReportingDescription' => 'Send fejlrapporter for at hjælpe med at forbedre appen',
|
||||||
'settings.debugLogging' => 'Fejlfindingslogning',
|
'settings.debugLogging' => 'Fejlfindingslogning',
|
||||||
@@ -2644,9 +2646,9 @@ extension on TranslationsDa {
|
|||||||
'messages.markedAsUnwatchedOffline' => 'Markeret som uset (synkroniseres online)',
|
'messages.markedAsUnwatchedOffline' => 'Markeret som uset (synkroniseres online)',
|
||||||
'messages.autoRemovedWatchedDownload' => ({required Object title}) => 'Automatisk fjernet: ${title}',
|
'messages.autoRemovedWatchedDownload' => ({required Object title}) => 'Automatisk fjernet: ${title}',
|
||||||
'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('da'))(n, one: 'Fjernede automatisk ${n} set download', other: 'Fjernede automatisk ${n} sete downloads', ),
|
'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('da'))(n, one: 'Fjernede automatisk ${n} set download', other: 'Fjernede automatisk ${n} sete downloads', ),
|
||||||
'messages.removedFromContinueWatching' => 'Fjernet fra Fortsæt med at se',
|
|
||||||
_ => null,
|
_ => null,
|
||||||
} ?? switch (path) {
|
} ?? switch (path) {
|
||||||
|
'messages.removedFromContinueWatching' => 'Fjernet fra Fortsæt med at se',
|
||||||
'messages.errorLoading' => ({required Object error}) => 'Fejl: ${error}',
|
'messages.errorLoading' => ({required Object error}) => 'Fejl: ${error}',
|
||||||
'messages.streamInterrupted' => 'Streamen blev afbrudt. Tryk på afspil, eller spol for at prøve igen.',
|
'messages.streamInterrupted' => 'Streamen blev afbrudt. Tryk på afspil, eller spol for at prøve igen.',
|
||||||
'messages.liveStreamInterrupted' => 'Livestreamen blev afbrudt. Tryk på afspil for at prøve igen.',
|
'messages.liveStreamInterrupted' => 'Livestreamen blev afbrudt. Tryk på afspil for at prøve igen.',
|
||||||
@@ -3158,9 +3160,9 @@ extension on TranslationsDa {
|
|||||||
'watchTogether.participantSeeked' => ({required Object name}) => '${name} spoled',
|
'watchTogether.participantSeeked' => ({required Object name}) => '${name} spoled',
|
||||||
'watchTogether.participantBuffering' => ({required Object name}) => '${name} bufferer',
|
'watchTogether.participantBuffering' => ({required Object name}) => '${name} bufferer',
|
||||||
'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} bruger en ældre appversion — synkronisering er ikke tilgængelig',
|
'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} bruger en ældre appversion — synkronisering er ikke tilgængelig',
|
||||||
'watchTogether.resumingWithout' => ({required Object name}) => 'Fortsætter uden ${name}',
|
|
||||||
_ => null,
|
_ => null,
|
||||||
} ?? switch (path) {
|
} ?? switch (path) {
|
||||||
|
'watchTogether.resumingWithout' => ({required Object name}) => 'Fortsætter uden ${name}',
|
||||||
'watchTogether.waitingForParticipants' => 'Venter på at andre indlæser...',
|
'watchTogether.waitingForParticipants' => 'Venter på at andre indlæser...',
|
||||||
'watchTogether.waitingForName' => ({required Object name}) => 'Venter på ${name}...',
|
'watchTogether.waitingForName' => ({required Object name}) => 'Venter på ${name}...',
|
||||||
'watchTogether.recentRooms' => 'Seneste rum',
|
'watchTogether.recentRooms' => 'Seneste rum',
|
||||||
|
|||||||
@@ -311,6 +311,7 @@ class _TranslationsSettingsDe extends TranslationsSettingsEn {
|
|||||||
@override String get watchTogetherRelay => 'Gemeinsam Schauen Relay';
|
@override String get watchTogetherRelay => 'Gemeinsam Schauen Relay';
|
||||||
@override String get watchTogetherRelayDescription => 'Eigenes Relay festlegen. Alle müssen denselben Server verwenden.';
|
@override String get watchTogetherRelayDescription => 'Eigenes Relay festlegen. Alle müssen denselben Server verwenden.';
|
||||||
@override String get watchTogetherRelayHint => 'https://mein-relay.beispiel.de';
|
@override String get watchTogetherRelayHint => 'https://mein-relay.beispiel.de';
|
||||||
|
@override String get watchTogetherRelayInvalid => '';
|
||||||
@override String get crashReporting => 'Absturzberichte';
|
@override String get crashReporting => 'Absturzberichte';
|
||||||
@override String get crashReportingDescription => 'Absturzberichte senden, um die App zu verbessern';
|
@override String get crashReportingDescription => 'Absturzberichte senden, um die App zu verbessern';
|
||||||
@override String get debugLogging => 'Debug-Protokollierung';
|
@override String get debugLogging => 'Debug-Protokollierung';
|
||||||
@@ -2303,6 +2304,7 @@ extension on TranslationsDe {
|
|||||||
'settings.watchTogetherRelay' => 'Gemeinsam Schauen Relay',
|
'settings.watchTogetherRelay' => 'Gemeinsam Schauen Relay',
|
||||||
'settings.watchTogetherRelayDescription' => 'Eigenes Relay festlegen. Alle müssen denselben Server verwenden.',
|
'settings.watchTogetherRelayDescription' => 'Eigenes Relay festlegen. Alle müssen denselben Server verwenden.',
|
||||||
'settings.watchTogetherRelayHint' => 'https://mein-relay.beispiel.de',
|
'settings.watchTogetherRelayHint' => 'https://mein-relay.beispiel.de',
|
||||||
|
'settings.watchTogetherRelayInvalid' => '',
|
||||||
'settings.crashReporting' => 'Absturzberichte',
|
'settings.crashReporting' => 'Absturzberichte',
|
||||||
'settings.crashReportingDescription' => 'Absturzberichte senden, um die App zu verbessern',
|
'settings.crashReportingDescription' => 'Absturzberichte senden, um die App zu verbessern',
|
||||||
'settings.debugLogging' => 'Debug-Protokollierung',
|
'settings.debugLogging' => 'Debug-Protokollierung',
|
||||||
@@ -2644,9 +2646,9 @@ extension on TranslationsDe {
|
|||||||
'messages.markedAsUnwatchedOffline' => 'Als ungesehen markiert (wird synchronisiert, wenn online)',
|
'messages.markedAsUnwatchedOffline' => 'Als ungesehen markiert (wird synchronisiert, wenn online)',
|
||||||
'messages.autoRemovedWatchedDownload' => ({required Object title}) => 'Automatisch entfernt: ${title}',
|
'messages.autoRemovedWatchedDownload' => ({required Object title}) => 'Automatisch entfernt: ${title}',
|
||||||
'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('de'))(n, one: 'Automatisch entfernt: ${n} angesehener Download', other: 'Automatisch entfernt: ${n} angesehene Downloads', ),
|
'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('de'))(n, one: 'Automatisch entfernt: ${n} angesehener Download', other: 'Automatisch entfernt: ${n} angesehene Downloads', ),
|
||||||
'messages.removedFromContinueWatching' => 'Aus ‚Weiterschauen\' entfernt',
|
|
||||||
_ => null,
|
_ => null,
|
||||||
} ?? switch (path) {
|
} ?? switch (path) {
|
||||||
|
'messages.removedFromContinueWatching' => 'Aus ‚Weiterschauen\' entfernt',
|
||||||
'messages.errorLoading' => ({required Object error}) => 'Fehler: ${error}',
|
'messages.errorLoading' => ({required Object error}) => 'Fehler: ${error}',
|
||||||
'messages.streamInterrupted' => 'Der Stream wurde unterbrochen. Drücke auf Wiedergabe oder spule, um es erneut zu versuchen.',
|
'messages.streamInterrupted' => 'Der Stream wurde unterbrochen. Drücke auf Wiedergabe oder spule, um es erneut zu versuchen.',
|
||||||
'messages.liveStreamInterrupted' => 'Der Livestream wurde unterbrochen. Drücke auf Wiedergabe, um es erneut zu versuchen.',
|
'messages.liveStreamInterrupted' => 'Der Livestream wurde unterbrochen. Drücke auf Wiedergabe, um es erneut zu versuchen.',
|
||||||
@@ -3158,9 +3160,9 @@ extension on TranslationsDe {
|
|||||||
'watchTogether.participantSeeked' => ({required Object name}) => '${name} hat gespult',
|
'watchTogether.participantSeeked' => ({required Object name}) => '${name} hat gespult',
|
||||||
'watchTogether.participantBuffering' => ({required Object name}) => '${name} puffert',
|
'watchTogether.participantBuffering' => ({required Object name}) => '${name} puffert',
|
||||||
'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} verwendet eine ältere Appversion — Synchronisierung nicht verfügbar',
|
'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} verwendet eine ältere Appversion — Synchronisierung nicht verfügbar',
|
||||||
'watchTogether.resumingWithout' => ({required Object name}) => 'Fortfahren ohne ${name}',
|
|
||||||
_ => null,
|
_ => null,
|
||||||
} ?? switch (path) {
|
} ?? switch (path) {
|
||||||
|
'watchTogether.resumingWithout' => ({required Object name}) => 'Fortfahren ohne ${name}',
|
||||||
'watchTogether.waitingForParticipants' => 'Warte auf andere zum Laden...',
|
'watchTogether.waitingForParticipants' => 'Warte auf andere zum Laden...',
|
||||||
'watchTogether.waitingForName' => ({required Object name}) => 'Warten auf ${name}...',
|
'watchTogether.waitingForName' => ({required Object name}) => 'Warten auf ${name}...',
|
||||||
'watchTogether.recentRooms' => 'Letzte Räume',
|
'watchTogether.recentRooms' => 'Letzte Räume',
|
||||||
|
|||||||
@@ -653,6 +653,9 @@ class TranslationsSettingsEn {
|
|||||||
/// en: 'https://my-relay.example.com'
|
/// en: 'https://my-relay.example.com'
|
||||||
String get watchTogetherRelayHint => 'https://my-relay.example.com';
|
String get watchTogetherRelayHint => 'https://my-relay.example.com';
|
||||||
|
|
||||||
|
/// en: 'Enter a valid HTTP or HTTPS relay base URL.'
|
||||||
|
String get watchTogetherRelayInvalid => 'Enter a valid HTTP or HTTPS relay base URL.';
|
||||||
|
|
||||||
/// en: 'Crash Reporting'
|
/// en: 'Crash Reporting'
|
||||||
String get crashReporting => 'Crash Reporting';
|
String get crashReporting => 'Crash Reporting';
|
||||||
|
|
||||||
@@ -5180,6 +5183,7 @@ extension on Translations {
|
|||||||
'settings.watchTogetherRelay' => 'Watch Together Relay',
|
'settings.watchTogetherRelay' => 'Watch Together Relay',
|
||||||
'settings.watchTogetherRelayDescription' => 'Set a custom relay. Everyone must use the same server.',
|
'settings.watchTogetherRelayDescription' => 'Set a custom relay. Everyone must use the same server.',
|
||||||
'settings.watchTogetherRelayHint' => 'https://my-relay.example.com',
|
'settings.watchTogetherRelayHint' => 'https://my-relay.example.com',
|
||||||
|
'settings.watchTogetherRelayInvalid' => 'Enter a valid HTTP or HTTPS relay base URL.',
|
||||||
'settings.crashReporting' => 'Crash Reporting',
|
'settings.crashReporting' => 'Crash Reporting',
|
||||||
'settings.crashReportingDescription' => 'Send crash reports to help improve the app',
|
'settings.crashReportingDescription' => 'Send crash reports to help improve the app',
|
||||||
'settings.debugLogging' => 'Debug Logging',
|
'settings.debugLogging' => 'Debug Logging',
|
||||||
@@ -5522,9 +5526,9 @@ extension on Translations {
|
|||||||
'messages.markedAsUnwatchedOffline' => 'Marked as unwatched (will sync when online)',
|
'messages.markedAsUnwatchedOffline' => 'Marked as unwatched (will sync when online)',
|
||||||
'messages.autoRemovedWatchedDownload' => ({required Object title}) => 'Auto-removed: ${title}',
|
'messages.autoRemovedWatchedDownload' => ({required Object title}) => 'Auto-removed: ${title}',
|
||||||
'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('en'))(n, one: 'Auto-removed ${n} watched download', other: 'Auto-removed ${n} watched downloads', ),
|
'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('en'))(n, one: 'Auto-removed ${n} watched download', other: 'Auto-removed ${n} watched downloads', ),
|
||||||
'messages.removedFromContinueWatching' => 'Removed from Continue Watching',
|
|
||||||
_ => null,
|
_ => null,
|
||||||
} ?? switch (path) {
|
} ?? switch (path) {
|
||||||
|
'messages.removedFromContinueWatching' => 'Removed from Continue Watching',
|
||||||
'messages.errorLoading' => ({required Object error}) => 'Error: ${error}',
|
'messages.errorLoading' => ({required Object error}) => 'Error: ${error}',
|
||||||
'messages.streamInterrupted' => 'The stream was interrupted. Press play or seek to retry.',
|
'messages.streamInterrupted' => 'The stream was interrupted. Press play or seek to retry.',
|
||||||
'messages.liveStreamInterrupted' => 'The live stream was interrupted. Press play to retry.',
|
'messages.liveStreamInterrupted' => 'The live stream was interrupted. Press play to retry.',
|
||||||
@@ -6037,9 +6041,9 @@ extension on Translations {
|
|||||||
'watchTogether.participantSeeked' => ({required Object name}) => '${name} seeked',
|
'watchTogether.participantSeeked' => ({required Object name}) => '${name} seeked',
|
||||||
'watchTogether.participantBuffering' => ({required Object name}) => '${name} is buffering',
|
'watchTogether.participantBuffering' => ({required Object name}) => '${name} is buffering',
|
||||||
'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} is on an older app version — sync unavailable',
|
'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} is on an older app version — sync unavailable',
|
||||||
'watchTogether.resumingWithout' => ({required Object name}) => 'Resuming without ${name}',
|
|
||||||
_ => null,
|
_ => null,
|
||||||
} ?? switch (path) {
|
} ?? switch (path) {
|
||||||
|
'watchTogether.resumingWithout' => ({required Object name}) => 'Resuming without ${name}',
|
||||||
'watchTogether.waitingForParticipants' => 'Waiting for others to load...',
|
'watchTogether.waitingForParticipants' => 'Waiting for others to load...',
|
||||||
'watchTogether.waitingForName' => ({required Object name}) => 'Waiting for ${name}...',
|
'watchTogether.waitingForName' => ({required Object name}) => 'Waiting for ${name}...',
|
||||||
'watchTogether.recentRooms' => 'Recent Rooms',
|
'watchTogether.recentRooms' => 'Recent Rooms',
|
||||||
|
|||||||
@@ -311,6 +311,7 @@ class _TranslationsSettingsEs extends TranslationsSettingsEn {
|
|||||||
@override String get watchTogetherRelay => 'Relay de Ver Juntos';
|
@override String get watchTogetherRelay => 'Relay de Ver Juntos';
|
||||||
@override String get watchTogetherRelayDescription => 'Define un relay personalizado. Todos deben usar el mismo servidor.';
|
@override String get watchTogetherRelayDescription => 'Define un relay personalizado. Todos deben usar el mismo servidor.';
|
||||||
@override String get watchTogetherRelayHint => 'https://mi-relay.ejemplo.com';
|
@override String get watchTogetherRelayHint => 'https://mi-relay.ejemplo.com';
|
||||||
|
@override String get watchTogetherRelayInvalid => '';
|
||||||
@override String get crashReporting => 'Informes de Errores';
|
@override String get crashReporting => 'Informes de Errores';
|
||||||
@override String get crashReportingDescription => 'Enviar informes de errores para mejorar la aplicación';
|
@override String get crashReportingDescription => 'Enviar informes de errores para mejorar la aplicación';
|
||||||
@override String get debugLogging => 'Registro de Depuración';
|
@override String get debugLogging => 'Registro de Depuración';
|
||||||
@@ -2303,6 +2304,7 @@ extension on TranslationsEs {
|
|||||||
'settings.watchTogetherRelay' => 'Relay de Ver Juntos',
|
'settings.watchTogetherRelay' => 'Relay de Ver Juntos',
|
||||||
'settings.watchTogetherRelayDescription' => 'Define un relay personalizado. Todos deben usar el mismo servidor.',
|
'settings.watchTogetherRelayDescription' => 'Define un relay personalizado. Todos deben usar el mismo servidor.',
|
||||||
'settings.watchTogetherRelayHint' => 'https://mi-relay.ejemplo.com',
|
'settings.watchTogetherRelayHint' => 'https://mi-relay.ejemplo.com',
|
||||||
|
'settings.watchTogetherRelayInvalid' => '',
|
||||||
'settings.crashReporting' => 'Informes de Errores',
|
'settings.crashReporting' => 'Informes de Errores',
|
||||||
'settings.crashReportingDescription' => 'Enviar informes de errores para mejorar la aplicación',
|
'settings.crashReportingDescription' => 'Enviar informes de errores para mejorar la aplicación',
|
||||||
'settings.debugLogging' => 'Registro de Depuración',
|
'settings.debugLogging' => 'Registro de Depuración',
|
||||||
@@ -2644,9 +2646,9 @@ extension on TranslationsEs {
|
|||||||
'messages.markedAsUnwatchedOffline' => 'Marcado como no visto (se sincronizará al estar en línea)',
|
'messages.markedAsUnwatchedOffline' => 'Marcado como no visto (se sincronizará al estar en línea)',
|
||||||
'messages.autoRemovedWatchedDownload' => ({required Object title}) => 'Eliminado automáticamente: ${title}',
|
'messages.autoRemovedWatchedDownload' => ({required Object title}) => 'Eliminado automáticamente: ${title}',
|
||||||
'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('es'))(n, one: 'Se eliminó automáticamente ${n} descarga vista', other: 'Se eliminaron automáticamente ${n} descargas vistas', ),
|
'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('es'))(n, one: 'Se eliminó automáticamente ${n} descarga vista', other: 'Se eliminaron automáticamente ${n} descargas vistas', ),
|
||||||
'messages.removedFromContinueWatching' => 'Eliminado de Seguir Viendo',
|
|
||||||
_ => null,
|
_ => null,
|
||||||
} ?? switch (path) {
|
} ?? switch (path) {
|
||||||
|
'messages.removedFromContinueWatching' => 'Eliminado de Seguir Viendo',
|
||||||
'messages.errorLoading' => ({required Object error}) => 'Error: ${error}',
|
'messages.errorLoading' => ({required Object error}) => 'Error: ${error}',
|
||||||
'messages.streamInterrupted' => 'La reproducción se interrumpió. Pulsa reproducir o avanza para volver a intentarlo.',
|
'messages.streamInterrupted' => 'La reproducción se interrumpió. Pulsa reproducir o avanza para volver a intentarlo.',
|
||||||
'messages.liveStreamInterrupted' => 'La transmisión en vivo se interrumpió. Pulsa reproducir para volver a intentarlo.',
|
'messages.liveStreamInterrupted' => 'La transmisión en vivo se interrumpió. Pulsa reproducir para volver a intentarlo.',
|
||||||
@@ -3158,9 +3160,9 @@ extension on TranslationsEs {
|
|||||||
'watchTogether.participantSeeked' => ({required Object name}) => '${name} avanzó',
|
'watchTogether.participantSeeked' => ({required Object name}) => '${name} avanzó',
|
||||||
'watchTogether.participantBuffering' => ({required Object name}) => '${name} está cargando',
|
'watchTogether.participantBuffering' => ({required Object name}) => '${name} está cargando',
|
||||||
'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} usa una versión anterior de la app — sincronización no disponible',
|
'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} usa una versión anterior de la app — sincronización no disponible',
|
||||||
'watchTogether.resumingWithout' => ({required Object name}) => 'Reanudando sin ${name}',
|
|
||||||
_ => null,
|
_ => null,
|
||||||
} ?? switch (path) {
|
} ?? switch (path) {
|
||||||
|
'watchTogether.resumingWithout' => ({required Object name}) => 'Reanudando sin ${name}',
|
||||||
'watchTogether.waitingForParticipants' => 'Esperando a que otros carguen...',
|
'watchTogether.waitingForParticipants' => 'Esperando a que otros carguen...',
|
||||||
'watchTogether.waitingForName' => ({required Object name}) => 'Esperando a ${name}...',
|
'watchTogether.waitingForName' => ({required Object name}) => 'Esperando a ${name}...',
|
||||||
'watchTogether.recentRooms' => 'Salas recientes',
|
'watchTogether.recentRooms' => 'Salas recientes',
|
||||||
|
|||||||
@@ -311,6 +311,7 @@ class _TranslationsSettingsFr extends TranslationsSettingsEn {
|
|||||||
@override String get watchTogetherRelay => 'Relais Regarder Ensemble';
|
@override String get watchTogetherRelay => 'Relais Regarder Ensemble';
|
||||||
@override String get watchTogetherRelayDescription => 'Définir un relay personnalisé. Tout le monde doit utiliser le même serveur.';
|
@override String get watchTogetherRelayDescription => 'Définir un relay personnalisé. Tout le monde doit utiliser le même serveur.';
|
||||||
@override String get watchTogetherRelayHint => 'https://mon-relais.exemple.fr';
|
@override String get watchTogetherRelayHint => 'https://mon-relais.exemple.fr';
|
||||||
|
@override String get watchTogetherRelayInvalid => '';
|
||||||
@override String get crashReporting => 'Rapports de plantage';
|
@override String get crashReporting => 'Rapports de plantage';
|
||||||
@override String get crashReportingDescription => 'Envoyer des rapports de plantage pour améliorer l\'application';
|
@override String get crashReportingDescription => 'Envoyer des rapports de plantage pour améliorer l\'application';
|
||||||
@override String get debugLogging => 'Journalisation de débogage';
|
@override String get debugLogging => 'Journalisation de débogage';
|
||||||
@@ -2303,6 +2304,7 @@ extension on TranslationsFr {
|
|||||||
'settings.watchTogetherRelay' => 'Relais Regarder Ensemble',
|
'settings.watchTogetherRelay' => 'Relais Regarder Ensemble',
|
||||||
'settings.watchTogetherRelayDescription' => 'Définir un relay personnalisé. Tout le monde doit utiliser le même serveur.',
|
'settings.watchTogetherRelayDescription' => 'Définir un relay personnalisé. Tout le monde doit utiliser le même serveur.',
|
||||||
'settings.watchTogetherRelayHint' => 'https://mon-relais.exemple.fr',
|
'settings.watchTogetherRelayHint' => 'https://mon-relais.exemple.fr',
|
||||||
|
'settings.watchTogetherRelayInvalid' => '',
|
||||||
'settings.crashReporting' => 'Rapports de plantage',
|
'settings.crashReporting' => 'Rapports de plantage',
|
||||||
'settings.crashReportingDescription' => 'Envoyer des rapports de plantage pour améliorer l\'application',
|
'settings.crashReportingDescription' => 'Envoyer des rapports de plantage pour améliorer l\'application',
|
||||||
'settings.debugLogging' => 'Journalisation de débogage',
|
'settings.debugLogging' => 'Journalisation de débogage',
|
||||||
@@ -2644,9 +2646,9 @@ extension on TranslationsFr {
|
|||||||
'messages.markedAsUnwatchedOffline' => 'Marqué comme non vu (sera synchronisé lorsque vous serez en ligne)',
|
'messages.markedAsUnwatchedOffline' => 'Marqué comme non vu (sera synchronisé lorsque vous serez en ligne)',
|
||||||
'messages.autoRemovedWatchedDownload' => ({required Object title}) => 'Supprimé automatiquement : ${title}',
|
'messages.autoRemovedWatchedDownload' => ({required Object title}) => 'Supprimé automatiquement : ${title}',
|
||||||
'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('fr'))(n, one: '${n} téléchargement vu supprimé automatiquement', other: '${n} téléchargements vus supprimés automatiquement', ),
|
'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('fr'))(n, one: '${n} téléchargement vu supprimé automatiquement', other: '${n} téléchargements vus supprimés automatiquement', ),
|
||||||
'messages.removedFromContinueWatching' => 'Supprimer de "Continuer à regarder"',
|
|
||||||
_ => null,
|
_ => null,
|
||||||
} ?? switch (path) {
|
} ?? switch (path) {
|
||||||
|
'messages.removedFromContinueWatching' => 'Supprimer de "Continuer à regarder"',
|
||||||
'messages.errorLoading' => ({required Object error}) => 'Erreur: ${error}',
|
'messages.errorLoading' => ({required Object error}) => 'Erreur: ${error}',
|
||||||
'messages.streamInterrupted' => 'La lecture a été interrompue. Appuyez sur Lecture ou avancez pour réessayer.',
|
'messages.streamInterrupted' => 'La lecture a été interrompue. Appuyez sur Lecture ou avancez pour réessayer.',
|
||||||
'messages.liveStreamInterrupted' => 'Le direct a été interrompu. Appuyez sur Lecture pour réessayer.',
|
'messages.liveStreamInterrupted' => 'Le direct a été interrompu. Appuyez sur Lecture pour réessayer.',
|
||||||
@@ -3158,9 +3160,9 @@ extension on TranslationsFr {
|
|||||||
'watchTogether.participantSeeked' => ({required Object name}) => '${name} a avancé',
|
'watchTogether.participantSeeked' => ({required Object name}) => '${name} a avancé',
|
||||||
'watchTogether.participantBuffering' => ({required Object name}) => '${name} met en mémoire tampon',
|
'watchTogether.participantBuffering' => ({required Object name}) => '${name} met en mémoire tampon',
|
||||||
'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} utilise une ancienne version de l’app — synchronisation indisponible',
|
'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} utilise une ancienne version de l’app — synchronisation indisponible',
|
||||||
'watchTogether.resumingWithout' => ({required Object name}) => 'Reprise sans ${name}',
|
|
||||||
_ => null,
|
_ => null,
|
||||||
} ?? switch (path) {
|
} ?? switch (path) {
|
||||||
|
'watchTogether.resumingWithout' => ({required Object name}) => 'Reprise sans ${name}',
|
||||||
'watchTogether.waitingForParticipants' => 'En attente du chargement des autres...',
|
'watchTogether.waitingForParticipants' => 'En attente du chargement des autres...',
|
||||||
'watchTogether.waitingForName' => ({required Object name}) => 'En attente de ${name}...',
|
'watchTogether.waitingForName' => ({required Object name}) => 'En attente de ${name}...',
|
||||||
'watchTogether.recentRooms' => 'Salons récents',
|
'watchTogether.recentRooms' => 'Salons récents',
|
||||||
|
|||||||
@@ -311,6 +311,7 @@ class _TranslationsSettingsIt extends TranslationsSettingsEn {
|
|||||||
@override String get watchTogetherRelay => 'Relay Guarda Insieme';
|
@override String get watchTogetherRelay => 'Relay Guarda Insieme';
|
||||||
@override String get watchTogetherRelayDescription => 'Imposta un relay personalizzato. Tutti devono usare lo stesso server.';
|
@override String get watchTogetherRelayDescription => 'Imposta un relay personalizzato. Tutti devono usare lo stesso server.';
|
||||||
@override String get watchTogetherRelayHint => 'https://mio-relay.esempio.it';
|
@override String get watchTogetherRelayHint => 'https://mio-relay.esempio.it';
|
||||||
|
@override String get watchTogetherRelayInvalid => '';
|
||||||
@override String get crashReporting => 'Segnalazione errori';
|
@override String get crashReporting => 'Segnalazione errori';
|
||||||
@override String get crashReportingDescription => 'Invia segnalazioni di errori per migliorare l\'app';
|
@override String get crashReportingDescription => 'Invia segnalazioni di errori per migliorare l\'app';
|
||||||
@override String get debugLogging => 'Log di debug';
|
@override String get debugLogging => 'Log di debug';
|
||||||
@@ -2303,6 +2304,7 @@ extension on TranslationsIt {
|
|||||||
'settings.watchTogetherRelay' => 'Relay Guarda Insieme',
|
'settings.watchTogetherRelay' => 'Relay Guarda Insieme',
|
||||||
'settings.watchTogetherRelayDescription' => 'Imposta un relay personalizzato. Tutti devono usare lo stesso server.',
|
'settings.watchTogetherRelayDescription' => 'Imposta un relay personalizzato. Tutti devono usare lo stesso server.',
|
||||||
'settings.watchTogetherRelayHint' => 'https://mio-relay.esempio.it',
|
'settings.watchTogetherRelayHint' => 'https://mio-relay.esempio.it',
|
||||||
|
'settings.watchTogetherRelayInvalid' => '',
|
||||||
'settings.crashReporting' => 'Segnalazione errori',
|
'settings.crashReporting' => 'Segnalazione errori',
|
||||||
'settings.crashReportingDescription' => 'Invia segnalazioni di errori per migliorare l\'app',
|
'settings.crashReportingDescription' => 'Invia segnalazioni di errori per migliorare l\'app',
|
||||||
'settings.debugLogging' => 'Log di debug',
|
'settings.debugLogging' => 'Log di debug',
|
||||||
@@ -2644,9 +2646,9 @@ extension on TranslationsIt {
|
|||||||
'messages.markedAsUnwatchedOffline' => 'Segnato come non visto (sincronizzato online)',
|
'messages.markedAsUnwatchedOffline' => 'Segnato come non visto (sincronizzato online)',
|
||||||
'messages.autoRemovedWatchedDownload' => ({required Object title}) => 'Rimosso automaticamente: ${title}',
|
'messages.autoRemovedWatchedDownload' => ({required Object title}) => 'Rimosso automaticamente: ${title}',
|
||||||
'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('it'))(n, one: 'Rimosso automaticamente ${n} download già visto', other: 'Rimossi automaticamente ${n} download già visti', ),
|
'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('it'))(n, one: 'Rimosso automaticamente ${n} download già visto', other: 'Rimossi automaticamente ${n} download già visti', ),
|
||||||
'messages.removedFromContinueWatching' => 'Rimosso da Continua a guardare',
|
|
||||||
_ => null,
|
_ => null,
|
||||||
} ?? switch (path) {
|
} ?? switch (path) {
|
||||||
|
'messages.removedFromContinueWatching' => 'Rimosso da Continua a guardare',
|
||||||
'messages.errorLoading' => ({required Object error}) => 'Errore: ${error}',
|
'messages.errorLoading' => ({required Object error}) => 'Errore: ${error}',
|
||||||
'messages.streamInterrupted' => 'La riproduzione si è interrotta. Premi Riproduci o scorri per riprovare.',
|
'messages.streamInterrupted' => 'La riproduzione si è interrotta. Premi Riproduci o scorri per riprovare.',
|
||||||
'messages.liveStreamInterrupted' => 'La diretta si è interrotta. Premi Riproduci per riprovare.',
|
'messages.liveStreamInterrupted' => 'La diretta si è interrotta. Premi Riproduci per riprovare.',
|
||||||
@@ -3158,9 +3160,9 @@ extension on TranslationsIt {
|
|||||||
'watchTogether.participantSeeked' => ({required Object name}) => '${name} ha cercato',
|
'watchTogether.participantSeeked' => ({required Object name}) => '${name} ha cercato',
|
||||||
'watchTogether.participantBuffering' => ({required Object name}) => '${name} sta caricando',
|
'watchTogether.participantBuffering' => ({required Object name}) => '${name} sta caricando',
|
||||||
'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} usa una versione precedente dell\'app — sincronizzazione non disponibile',
|
'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} usa una versione precedente dell\'app — sincronizzazione non disponibile',
|
||||||
'watchTogether.resumingWithout' => ({required Object name}) => 'Ripresa senza ${name}',
|
|
||||||
_ => null,
|
_ => null,
|
||||||
} ?? switch (path) {
|
} ?? switch (path) {
|
||||||
|
'watchTogether.resumingWithout' => ({required Object name}) => 'Ripresa senza ${name}',
|
||||||
'watchTogether.waitingForParticipants' => 'In attesa che gli altri carichino...',
|
'watchTogether.waitingForParticipants' => 'In attesa che gli altri carichino...',
|
||||||
'watchTogether.waitingForName' => ({required Object name}) => 'In attesa di ${name}...',
|
'watchTogether.waitingForName' => ({required Object name}) => 'In attesa di ${name}...',
|
||||||
'watchTogether.recentRooms' => 'Stanze recenti',
|
'watchTogether.recentRooms' => 'Stanze recenti',
|
||||||
|
|||||||
@@ -311,6 +311,7 @@ class _TranslationsSettingsJa extends TranslationsSettingsEn {
|
|||||||
@override String get watchTogetherRelay => '一緒に視聴リレーサーバー';
|
@override String get watchTogetherRelay => '一緒に視聴リレーサーバー';
|
||||||
@override String get watchTogetherRelayDescription => 'カスタムリレーを設定します。全員が同じサーバーを使う必要があります。';
|
@override String get watchTogetherRelayDescription => 'カスタムリレーを設定します。全員が同じサーバーを使う必要があります。';
|
||||||
@override String get watchTogetherRelayHint => 'https://my-relay.example.com';
|
@override String get watchTogetherRelayHint => 'https://my-relay.example.com';
|
||||||
|
@override String get watchTogetherRelayInvalid => '';
|
||||||
@override String get crashReporting => 'クラッシュレポート';
|
@override String get crashReporting => 'クラッシュレポート';
|
||||||
@override String get crashReportingDescription => 'アプリの改善に役立つクラッシュレポートを送信';
|
@override String get crashReportingDescription => 'アプリの改善に役立つクラッシュレポートを送信';
|
||||||
@override String get debugLogging => 'デバッグログ';
|
@override String get debugLogging => 'デバッグログ';
|
||||||
@@ -2300,6 +2301,7 @@ extension on TranslationsJa {
|
|||||||
'settings.watchTogetherRelay' => '一緒に視聴リレーサーバー',
|
'settings.watchTogetherRelay' => '一緒に視聴リレーサーバー',
|
||||||
'settings.watchTogetherRelayDescription' => 'カスタムリレーを設定します。全員が同じサーバーを使う必要があります。',
|
'settings.watchTogetherRelayDescription' => 'カスタムリレーを設定します。全員が同じサーバーを使う必要があります。',
|
||||||
'settings.watchTogetherRelayHint' => 'https://my-relay.example.com',
|
'settings.watchTogetherRelayHint' => 'https://my-relay.example.com',
|
||||||
|
'settings.watchTogetherRelayInvalid' => '',
|
||||||
'settings.crashReporting' => 'クラッシュレポート',
|
'settings.crashReporting' => 'クラッシュレポート',
|
||||||
'settings.crashReportingDescription' => 'アプリの改善に役立つクラッシュレポートを送信',
|
'settings.crashReportingDescription' => 'アプリの改善に役立つクラッシュレポートを送信',
|
||||||
'settings.debugLogging' => 'デバッグログ',
|
'settings.debugLogging' => 'デバッグログ',
|
||||||
@@ -2641,9 +2643,9 @@ extension on TranslationsJa {
|
|||||||
'messages.markedAsUnwatchedOffline' => '未視聴にしました(オンライン時に同期)',
|
'messages.markedAsUnwatchedOffline' => '未視聴にしました(オンライン時に同期)',
|
||||||
'messages.autoRemovedWatchedDownload' => ({required Object title}) => '自動削除: ${title}',
|
'messages.autoRemovedWatchedDownload' => ({required Object title}) => '自動削除: ${title}',
|
||||||
'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('ja'))(n, other: '視聴済みダウンロードを${n}件自動削除しました', ),
|
'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('ja'))(n, other: '視聴済みダウンロードを${n}件自動削除しました', ),
|
||||||
'messages.removedFromContinueWatching' => '視聴中から削除しました',
|
|
||||||
_ => null,
|
_ => null,
|
||||||
} ?? switch (path) {
|
} ?? switch (path) {
|
||||||
|
'messages.removedFromContinueWatching' => '視聴中から削除しました',
|
||||||
'messages.errorLoading' => ({required Object error}) => 'エラー: ${error}',
|
'messages.errorLoading' => ({required Object error}) => 'エラー: ${error}',
|
||||||
'messages.streamInterrupted' => 'ストリームが中断されました。再生を押すかシークして再試行してください。',
|
'messages.streamInterrupted' => 'ストリームが中断されました。再生を押すかシークして再試行してください。',
|
||||||
'messages.liveStreamInterrupted' => 'ライブストリームが中断されました。再生を押して再試行してください。',
|
'messages.liveStreamInterrupted' => 'ライブストリームが中断されました。再生を押して再試行してください。',
|
||||||
@@ -3155,9 +3157,9 @@ extension on TranslationsJa {
|
|||||||
'watchTogether.participantSeeked' => ({required Object name}) => '${name}がシークしました',
|
'watchTogether.participantSeeked' => ({required Object name}) => '${name}がシークしました',
|
||||||
'watchTogether.participantBuffering' => ({required Object name}) => '${name}がバッファリング中',
|
'watchTogether.participantBuffering' => ({required Object name}) => '${name}がバッファリング中',
|
||||||
'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} は古いバージョンのアプリを使用しているため、同期できません',
|
'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} は古いバージョンのアプリを使用しているため、同期できません',
|
||||||
'watchTogether.resumingWithout' => ({required Object name}) => '${name} なしで再開',
|
|
||||||
_ => null,
|
_ => null,
|
||||||
} ?? switch (path) {
|
} ?? switch (path) {
|
||||||
|
'watchTogether.resumingWithout' => ({required Object name}) => '${name} なしで再開',
|
||||||
'watchTogether.waitingForParticipants' => '他の参加者の読み込みを待っています...',
|
'watchTogether.waitingForParticipants' => '他の参加者の読み込みを待っています...',
|
||||||
'watchTogether.waitingForName' => ({required Object name}) => '${name}を待っています...',
|
'watchTogether.waitingForName' => ({required Object name}) => '${name}を待っています...',
|
||||||
'watchTogether.recentRooms' => '最近のルーム',
|
'watchTogether.recentRooms' => '最近のルーム',
|
||||||
|
|||||||
@@ -311,6 +311,7 @@ class _TranslationsSettingsKo extends TranslationsSettingsEn {
|
|||||||
@override String get watchTogetherRelay => '함께 보기 릴레이';
|
@override String get watchTogetherRelay => '함께 보기 릴레이';
|
||||||
@override String get watchTogetherRelayDescription => '사용자 지정 릴레이를 설정합니다. 모두 같은 서버를 사용해야 합니다.';
|
@override String get watchTogetherRelayDescription => '사용자 지정 릴레이를 설정합니다. 모두 같은 서버를 사용해야 합니다.';
|
||||||
@override String get watchTogetherRelayHint => 'https://my-relay.example.com';
|
@override String get watchTogetherRelayHint => 'https://my-relay.example.com';
|
||||||
|
@override String get watchTogetherRelayInvalid => '';
|
||||||
@override String get crashReporting => '충돌 보고';
|
@override String get crashReporting => '충돌 보고';
|
||||||
@override String get crashReportingDescription => '앱 개선을 위해 충돌 보고서 전송';
|
@override String get crashReportingDescription => '앱 개선을 위해 충돌 보고서 전송';
|
||||||
@override String get debugLogging => '디버그 로깅';
|
@override String get debugLogging => '디버그 로깅';
|
||||||
@@ -2300,6 +2301,7 @@ extension on TranslationsKo {
|
|||||||
'settings.watchTogetherRelay' => '함께 보기 릴레이',
|
'settings.watchTogetherRelay' => '함께 보기 릴레이',
|
||||||
'settings.watchTogetherRelayDescription' => '사용자 지정 릴레이를 설정합니다. 모두 같은 서버를 사용해야 합니다.',
|
'settings.watchTogetherRelayDescription' => '사용자 지정 릴레이를 설정합니다. 모두 같은 서버를 사용해야 합니다.',
|
||||||
'settings.watchTogetherRelayHint' => 'https://my-relay.example.com',
|
'settings.watchTogetherRelayHint' => 'https://my-relay.example.com',
|
||||||
|
'settings.watchTogetherRelayInvalid' => '',
|
||||||
'settings.crashReporting' => '충돌 보고',
|
'settings.crashReporting' => '충돌 보고',
|
||||||
'settings.crashReportingDescription' => '앱 개선을 위해 충돌 보고서 전송',
|
'settings.crashReportingDescription' => '앱 개선을 위해 충돌 보고서 전송',
|
||||||
'settings.debugLogging' => '디버그 로깅',
|
'settings.debugLogging' => '디버그 로깅',
|
||||||
@@ -2641,9 +2643,9 @@ extension on TranslationsKo {
|
|||||||
'messages.markedAsUnwatchedOffline' => '미시청으로 표시됨 (연결 시 동기화됨)',
|
'messages.markedAsUnwatchedOffline' => '미시청으로 표시됨 (연결 시 동기화됨)',
|
||||||
'messages.autoRemovedWatchedDownload' => ({required Object title}) => '자동 삭제됨: ${title}',
|
'messages.autoRemovedWatchedDownload' => ({required Object title}) => '자동 삭제됨: ${title}',
|
||||||
'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('ko'))(n, other: '시청한 다운로드 ${n}개를 자동 삭제했습니다', ),
|
'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('ko'))(n, other: '시청한 다운로드 ${n}개를 자동 삭제했습니다', ),
|
||||||
'messages.removedFromContinueWatching' => '계속 시청 목록에서 제거됨',
|
|
||||||
_ => null,
|
_ => null,
|
||||||
} ?? switch (path) {
|
} ?? switch (path) {
|
||||||
|
'messages.removedFromContinueWatching' => '계속 시청 목록에서 제거됨',
|
||||||
'messages.errorLoading' => ({required Object error}) => '오류: ${error}',
|
'messages.errorLoading' => ({required Object error}) => '오류: ${error}',
|
||||||
'messages.streamInterrupted' => '스트림이 중단되었습니다. 재생을 누르거나 탐색하여 다시 시도하세요.',
|
'messages.streamInterrupted' => '스트림이 중단되었습니다. 재생을 누르거나 탐색하여 다시 시도하세요.',
|
||||||
'messages.liveStreamInterrupted' => '라이브 스트림이 중단되었습니다. 재생을 눌러 다시 시도하세요.',
|
'messages.liveStreamInterrupted' => '라이브 스트림이 중단되었습니다. 재생을 눌러 다시 시도하세요.',
|
||||||
@@ -3155,9 +3157,9 @@ extension on TranslationsKo {
|
|||||||
'watchTogether.participantSeeked' => ({required Object name}) => '${name}님이 탐색했습니다',
|
'watchTogether.participantSeeked' => ({required Object name}) => '${name}님이 탐색했습니다',
|
||||||
'watchTogether.participantBuffering' => ({required Object name}) => '${name}님이 버퍼링 중입니다',
|
'watchTogether.participantBuffering' => ({required Object name}) => '${name}님이 버퍼링 중입니다',
|
||||||
'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name}님이 이전 버전의 앱을 사용 중입니다 — 동기화를 사용할 수 없습니다',
|
'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name}님이 이전 버전의 앱을 사용 중입니다 — 동기화를 사용할 수 없습니다',
|
||||||
'watchTogether.resumingWithout' => ({required Object name}) => '${name}님 없이 재생을 재개합니다',
|
|
||||||
_ => null,
|
_ => null,
|
||||||
} ?? switch (path) {
|
} ?? switch (path) {
|
||||||
|
'watchTogether.resumingWithout' => ({required Object name}) => '${name}님 없이 재생을 재개합니다',
|
||||||
'watchTogether.waitingForParticipants' => '다른 참가자의 로딩을 기다리는 중...',
|
'watchTogether.waitingForParticipants' => '다른 참가자의 로딩을 기다리는 중...',
|
||||||
'watchTogether.waitingForName' => ({required Object name}) => '${name}님을 기다리는 중...',
|
'watchTogether.waitingForName' => ({required Object name}) => '${name}님을 기다리는 중...',
|
||||||
'watchTogether.recentRooms' => '최근 방',
|
'watchTogether.recentRooms' => '최근 방',
|
||||||
|
|||||||
@@ -311,6 +311,7 @@ class _TranslationsSettingsNb extends TranslationsSettingsEn {
|
|||||||
@override String get watchTogetherRelay => 'Se Sammen-relay';
|
@override String get watchTogetherRelay => 'Se Sammen-relay';
|
||||||
@override String get watchTogetherRelayDescription => 'Angi en egendefinert relay. Alle må bruke samme server.';
|
@override String get watchTogetherRelayDescription => 'Angi en egendefinert relay. Alle må bruke samme server.';
|
||||||
@override String get watchTogetherRelayHint => 'https://min-relay.eksempel.no';
|
@override String get watchTogetherRelayHint => 'https://min-relay.eksempel.no';
|
||||||
|
@override String get watchTogetherRelayInvalid => '';
|
||||||
@override String get crashReporting => 'Krasjrapportering';
|
@override String get crashReporting => 'Krasjrapportering';
|
||||||
@override String get crashReportingDescription => 'Send krasjrapporter for å hjelpe med å forbedre appen';
|
@override String get crashReportingDescription => 'Send krasjrapporter for å hjelpe med å forbedre appen';
|
||||||
@override String get debugLogging => 'Feilsøkingslogging';
|
@override String get debugLogging => 'Feilsøkingslogging';
|
||||||
@@ -2303,6 +2304,7 @@ extension on TranslationsNb {
|
|||||||
'settings.watchTogetherRelay' => 'Se Sammen-relay',
|
'settings.watchTogetherRelay' => 'Se Sammen-relay',
|
||||||
'settings.watchTogetherRelayDescription' => 'Angi en egendefinert relay. Alle må bruke samme server.',
|
'settings.watchTogetherRelayDescription' => 'Angi en egendefinert relay. Alle må bruke samme server.',
|
||||||
'settings.watchTogetherRelayHint' => 'https://min-relay.eksempel.no',
|
'settings.watchTogetherRelayHint' => 'https://min-relay.eksempel.no',
|
||||||
|
'settings.watchTogetherRelayInvalid' => '',
|
||||||
'settings.crashReporting' => 'Krasjrapportering',
|
'settings.crashReporting' => 'Krasjrapportering',
|
||||||
'settings.crashReportingDescription' => 'Send krasjrapporter for å hjelpe med å forbedre appen',
|
'settings.crashReportingDescription' => 'Send krasjrapporter for å hjelpe med å forbedre appen',
|
||||||
'settings.debugLogging' => 'Feilsøkingslogging',
|
'settings.debugLogging' => 'Feilsøkingslogging',
|
||||||
@@ -2644,9 +2646,9 @@ extension on TranslationsNb {
|
|||||||
'messages.markedAsUnwatchedOffline' => 'Merket som usett (synkroniseres når tilkoblet)',
|
'messages.markedAsUnwatchedOffline' => 'Merket som usett (synkroniseres når tilkoblet)',
|
||||||
'messages.autoRemovedWatchedDownload' => ({required Object title}) => 'Automatisk fjernet: ${title}',
|
'messages.autoRemovedWatchedDownload' => ({required Object title}) => 'Automatisk fjernet: ${title}',
|
||||||
'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('nb'))(n, one: 'Fjernet automatisk ${n} sett nedlasting', other: 'Fjernet automatisk ${n} sette nedlastinger', ),
|
'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('nb'))(n, one: 'Fjernet automatisk ${n} sett nedlasting', other: 'Fjernet automatisk ${n} sette nedlastinger', ),
|
||||||
'messages.removedFromContinueWatching' => 'Fjernet fra Fortsett å se',
|
|
||||||
_ => null,
|
_ => null,
|
||||||
} ?? switch (path) {
|
} ?? switch (path) {
|
||||||
|
'messages.removedFromContinueWatching' => 'Fjernet fra Fortsett å se',
|
||||||
'messages.errorLoading' => ({required Object error}) => 'Feil: ${error}',
|
'messages.errorLoading' => ({required Object error}) => 'Feil: ${error}',
|
||||||
'messages.streamInterrupted' => 'Avspillingen ble avbrutt. Trykk på Spill av eller spol for å prøve på nytt.',
|
'messages.streamInterrupted' => 'Avspillingen ble avbrutt. Trykk på Spill av eller spol for å prøve på nytt.',
|
||||||
'messages.liveStreamInterrupted' => 'Direktesendingen ble avbrutt. Trykk på Spill av for å prøve på nytt.',
|
'messages.liveStreamInterrupted' => 'Direktesendingen ble avbrutt. Trykk på Spill av for å prøve på nytt.',
|
||||||
@@ -3158,9 +3160,9 @@ extension on TranslationsNb {
|
|||||||
'watchTogether.participantSeeked' => ({required Object name}) => '${name} spolet',
|
'watchTogether.participantSeeked' => ({required Object name}) => '${name} spolet',
|
||||||
'watchTogether.participantBuffering' => ({required Object name}) => '${name} buffrer',
|
'watchTogether.participantBuffering' => ({required Object name}) => '${name} buffrer',
|
||||||
'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} bruker en eldre appversjon — synkronisering er ikke tilgjengelig',
|
'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} bruker en eldre appversjon — synkronisering er ikke tilgjengelig',
|
||||||
'watchTogether.resumingWithout' => ({required Object name}) => 'Fortsetter uten ${name}',
|
|
||||||
_ => null,
|
_ => null,
|
||||||
} ?? switch (path) {
|
} ?? switch (path) {
|
||||||
|
'watchTogether.resumingWithout' => ({required Object name}) => 'Fortsetter uten ${name}',
|
||||||
'watchTogether.waitingForParticipants' => 'Venter på at andre laster inn...',
|
'watchTogether.waitingForParticipants' => 'Venter på at andre laster inn...',
|
||||||
'watchTogether.waitingForName' => ({required Object name}) => 'Venter på ${name}...',
|
'watchTogether.waitingForName' => ({required Object name}) => 'Venter på ${name}...',
|
||||||
'watchTogether.recentRooms' => 'Nylige rom',
|
'watchTogether.recentRooms' => 'Nylige rom',
|
||||||
|
|||||||
@@ -311,6 +311,7 @@ class _TranslationsSettingsNl extends TranslationsSettingsEn {
|
|||||||
@override String get watchTogetherRelay => 'Samen Kijken Relay';
|
@override String get watchTogetherRelay => 'Samen Kijken Relay';
|
||||||
@override String get watchTogetherRelayDescription => 'Stel een aangepaste relay in. Iedereen moet dezelfde server gebruiken.';
|
@override String get watchTogetherRelayDescription => 'Stel een aangepaste relay in. Iedereen moet dezelfde server gebruiken.';
|
||||||
@override String get watchTogetherRelayHint => 'https://mijn-relay.voorbeeld.nl';
|
@override String get watchTogetherRelayHint => 'https://mijn-relay.voorbeeld.nl';
|
||||||
|
@override String get watchTogetherRelayInvalid => '';
|
||||||
@override String get crashReporting => 'Crashrapportage';
|
@override String get crashReporting => 'Crashrapportage';
|
||||||
@override String get crashReportingDescription => 'Crashrapporten verzenden om de app te verbeteren';
|
@override String get crashReportingDescription => 'Crashrapporten verzenden om de app te verbeteren';
|
||||||
@override String get debugLogging => 'Debug logging';
|
@override String get debugLogging => 'Debug logging';
|
||||||
@@ -2303,6 +2304,7 @@ extension on TranslationsNl {
|
|||||||
'settings.watchTogetherRelay' => 'Samen Kijken Relay',
|
'settings.watchTogetherRelay' => 'Samen Kijken Relay',
|
||||||
'settings.watchTogetherRelayDescription' => 'Stel een aangepaste relay in. Iedereen moet dezelfde server gebruiken.',
|
'settings.watchTogetherRelayDescription' => 'Stel een aangepaste relay in. Iedereen moet dezelfde server gebruiken.',
|
||||||
'settings.watchTogetherRelayHint' => 'https://mijn-relay.voorbeeld.nl',
|
'settings.watchTogetherRelayHint' => 'https://mijn-relay.voorbeeld.nl',
|
||||||
|
'settings.watchTogetherRelayInvalid' => '',
|
||||||
'settings.crashReporting' => 'Crashrapportage',
|
'settings.crashReporting' => 'Crashrapportage',
|
||||||
'settings.crashReportingDescription' => 'Crashrapporten verzenden om de app te verbeteren',
|
'settings.crashReportingDescription' => 'Crashrapporten verzenden om de app te verbeteren',
|
||||||
'settings.debugLogging' => 'Debug logging',
|
'settings.debugLogging' => 'Debug logging',
|
||||||
@@ -2644,9 +2646,9 @@ extension on TranslationsNl {
|
|||||||
'messages.markedAsUnwatchedOffline' => 'Gemarkeerd als ongekeken (sync wanneer online)',
|
'messages.markedAsUnwatchedOffline' => 'Gemarkeerd als ongekeken (sync wanneer online)',
|
||||||
'messages.autoRemovedWatchedDownload' => ({required Object title}) => 'Automatisch verwijderd: ${title}',
|
'messages.autoRemovedWatchedDownload' => ({required Object title}) => 'Automatisch verwijderd: ${title}',
|
||||||
'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('nl'))(n, one: 'Automatisch ${n} bekeken download verwijderd', other: 'Automatisch ${n} bekeken downloads verwijderd', ),
|
'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('nl'))(n, one: 'Automatisch ${n} bekeken download verwijderd', other: 'Automatisch ${n} bekeken downloads verwijderd', ),
|
||||||
'messages.removedFromContinueWatching' => 'Verwijderd uit Doorgaan met kijken',
|
|
||||||
_ => null,
|
_ => null,
|
||||||
} ?? switch (path) {
|
} ?? switch (path) {
|
||||||
|
'messages.removedFromContinueWatching' => 'Verwijderd uit Doorgaan met kijken',
|
||||||
'messages.errorLoading' => ({required Object error}) => 'Fout: ${error}',
|
'messages.errorLoading' => ({required Object error}) => 'Fout: ${error}',
|
||||||
'messages.streamInterrupted' => 'De stream is onderbroken. Druk op afspelen of spoel om het opnieuw te proberen.',
|
'messages.streamInterrupted' => 'De stream is onderbroken. Druk op afspelen of spoel om het opnieuw te proberen.',
|
||||||
'messages.liveStreamInterrupted' => 'De livestream is onderbroken. Druk op afspelen om het opnieuw te proberen.',
|
'messages.liveStreamInterrupted' => 'De livestream is onderbroken. Druk op afspelen om het opnieuw te proberen.',
|
||||||
@@ -3158,9 +3160,9 @@ extension on TranslationsNl {
|
|||||||
'watchTogether.participantSeeked' => ({required Object name}) => '${name} heeft gespoeld',
|
'watchTogether.participantSeeked' => ({required Object name}) => '${name} heeft gespoeld',
|
||||||
'watchTogether.participantBuffering' => ({required Object name}) => '${name} is aan het bufferen',
|
'watchTogether.participantBuffering' => ({required Object name}) => '${name} is aan het bufferen',
|
||||||
'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} gebruikt een oudere appversie — synchronisatie niet beschikbaar',
|
'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} gebruikt een oudere appversie — synchronisatie niet beschikbaar',
|
||||||
'watchTogether.resumingWithout' => ({required Object name}) => 'Hervatten zonder ${name}',
|
|
||||||
_ => null,
|
_ => null,
|
||||||
} ?? switch (path) {
|
} ?? switch (path) {
|
||||||
|
'watchTogether.resumingWithout' => ({required Object name}) => 'Hervatten zonder ${name}',
|
||||||
'watchTogether.waitingForParticipants' => 'Wachten tot anderen geladen zijn...',
|
'watchTogether.waitingForParticipants' => 'Wachten tot anderen geladen zijn...',
|
||||||
'watchTogether.waitingForName' => ({required Object name}) => 'Wachten op ${name}...',
|
'watchTogether.waitingForName' => ({required Object name}) => 'Wachten op ${name}...',
|
||||||
'watchTogether.recentRooms' => 'Recente kamers',
|
'watchTogether.recentRooms' => 'Recente kamers',
|
||||||
|
|||||||
@@ -311,6 +311,7 @@ class _TranslationsSettingsPl extends TranslationsSettingsEn {
|
|||||||
@override String get watchTogetherRelay => 'Relay Oglądaj Razem';
|
@override String get watchTogetherRelay => 'Relay Oglądaj Razem';
|
||||||
@override String get watchTogetherRelayDescription => 'Ustaw własny relay. Wszyscy muszą używać tego samego serwera.';
|
@override String get watchTogetherRelayDescription => 'Ustaw własny relay. Wszyscy muszą używać tego samego serwera.';
|
||||||
@override String get watchTogetherRelayHint => 'https://moj-relay.przyklad.pl';
|
@override String get watchTogetherRelayHint => 'https://moj-relay.przyklad.pl';
|
||||||
|
@override String get watchTogetherRelayInvalid => '';
|
||||||
@override String get crashReporting => 'Raportowanie błędów';
|
@override String get crashReporting => 'Raportowanie błędów';
|
||||||
@override String get crashReportingDescription => 'Wysyłaj raporty o błędach, aby pomóc ulepszyć aplikację';
|
@override String get crashReportingDescription => 'Wysyłaj raporty o błędach, aby pomóc ulepszyć aplikację';
|
||||||
@override String get debugLogging => 'Logowanie debugowania';
|
@override String get debugLogging => 'Logowanie debugowania';
|
||||||
@@ -2309,6 +2310,7 @@ extension on TranslationsPl {
|
|||||||
'settings.watchTogetherRelay' => 'Relay Oglądaj Razem',
|
'settings.watchTogetherRelay' => 'Relay Oglądaj Razem',
|
||||||
'settings.watchTogetherRelayDescription' => 'Ustaw własny relay. Wszyscy muszą używać tego samego serwera.',
|
'settings.watchTogetherRelayDescription' => 'Ustaw własny relay. Wszyscy muszą używać tego samego serwera.',
|
||||||
'settings.watchTogetherRelayHint' => 'https://moj-relay.przyklad.pl',
|
'settings.watchTogetherRelayHint' => 'https://moj-relay.przyklad.pl',
|
||||||
|
'settings.watchTogetherRelayInvalid' => '',
|
||||||
'settings.crashReporting' => 'Raportowanie błędów',
|
'settings.crashReporting' => 'Raportowanie błędów',
|
||||||
'settings.crashReportingDescription' => 'Wysyłaj raporty o błędach, aby pomóc ulepszyć aplikację',
|
'settings.crashReportingDescription' => 'Wysyłaj raporty o błędach, aby pomóc ulepszyć aplikację',
|
||||||
'settings.debugLogging' => 'Logowanie debugowania',
|
'settings.debugLogging' => 'Logowanie debugowania',
|
||||||
@@ -2650,9 +2652,9 @@ extension on TranslationsPl {
|
|||||||
'messages.markedAsUnwatchedOffline' => 'Oznaczono jako nieobejrzane (zsynchronizuje się po połączeniu)',
|
'messages.markedAsUnwatchedOffline' => 'Oznaczono jako nieobejrzane (zsynchronizuje się po połączeniu)',
|
||||||
'messages.autoRemovedWatchedDownload' => ({required Object title}) => 'Automatycznie usunięto: ${title}',
|
'messages.autoRemovedWatchedDownload' => ({required Object title}) => 'Automatycznie usunięto: ${title}',
|
||||||
'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('pl'))(n, one: 'Automatycznie usunięto ${n} obejrzane pobranie', few: 'Automatycznie usunięto ${n} obejrzane pobrania', many: 'Automatycznie usunięto ${n} obejrzanych pobrań', other: 'Automatycznie usunięto ${n} obejrzanego pobrania', ),
|
'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('pl'))(n, one: 'Automatycznie usunięto ${n} obejrzane pobranie', few: 'Automatycznie usunięto ${n} obejrzane pobrania', many: 'Automatycznie usunięto ${n} obejrzanych pobrań', other: 'Automatycznie usunięto ${n} obejrzanego pobrania', ),
|
||||||
'messages.removedFromContinueWatching' => 'Usunięto z kontynuowania oglądania',
|
|
||||||
_ => null,
|
_ => null,
|
||||||
} ?? switch (path) {
|
} ?? switch (path) {
|
||||||
|
'messages.removedFromContinueWatching' => 'Usunięto z kontynuowania oglądania',
|
||||||
'messages.errorLoading' => ({required Object error}) => 'Błąd: ${error}',
|
'messages.errorLoading' => ({required Object error}) => 'Błąd: ${error}',
|
||||||
'messages.streamInterrupted' => 'Strumień został przerwany. Naciśnij odtwarzanie lub przewiń, aby spróbować ponownie.',
|
'messages.streamInterrupted' => 'Strumień został przerwany. Naciśnij odtwarzanie lub przewiń, aby spróbować ponownie.',
|
||||||
'messages.liveStreamInterrupted' => 'Transmisja na żywo została przerwana. Naciśnij odtwarzanie, aby spróbować ponownie.',
|
'messages.liveStreamInterrupted' => 'Transmisja na żywo została przerwana. Naciśnij odtwarzanie, aby spróbować ponownie.',
|
||||||
@@ -3164,9 +3166,9 @@ extension on TranslationsPl {
|
|||||||
'watchTogether.participantSeeked' => ({required Object name}) => '${name} przewinął',
|
'watchTogether.participantSeeked' => ({required Object name}) => '${name} przewinął',
|
||||||
'watchTogether.participantBuffering' => ({required Object name}) => '${name} buforuje',
|
'watchTogether.participantBuffering' => ({required Object name}) => '${name} buforuje',
|
||||||
'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} używa starszej wersji aplikacji — synchronizacja jest niedostępna',
|
'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} używa starszej wersji aplikacji — synchronizacja jest niedostępna',
|
||||||
'watchTogether.resumingWithout' => ({required Object name}) => 'Wznawianie bez ${name}',
|
|
||||||
_ => null,
|
_ => null,
|
||||||
} ?? switch (path) {
|
} ?? switch (path) {
|
||||||
|
'watchTogether.resumingWithout' => ({required Object name}) => 'Wznawianie bez ${name}',
|
||||||
'watchTogether.waitingForParticipants' => 'Oczekiwanie na załadowanie u innych...',
|
'watchTogether.waitingForParticipants' => 'Oczekiwanie na załadowanie u innych...',
|
||||||
'watchTogether.waitingForName' => ({required Object name}) => 'Oczekiwanie na ${name}...',
|
'watchTogether.waitingForName' => ({required Object name}) => 'Oczekiwanie na ${name}...',
|
||||||
'watchTogether.recentRooms' => 'Ostatnie pokoje',
|
'watchTogether.recentRooms' => 'Ostatnie pokoje',
|
||||||
|
|||||||
@@ -311,6 +311,7 @@ class _TranslationsSettingsPt extends TranslationsSettingsEn {
|
|||||||
@override String get watchTogetherRelay => 'Relay do Assistir Juntos';
|
@override String get watchTogetherRelay => 'Relay do Assistir Juntos';
|
||||||
@override String get watchTogetherRelayDescription => 'Defina um relay personalizado. Todos devem usar o mesmo servidor.';
|
@override String get watchTogetherRelayDescription => 'Defina um relay personalizado. Todos devem usar o mesmo servidor.';
|
||||||
@override String get watchTogetherRelayHint => 'https://meu-relay.exemplo.com.br';
|
@override String get watchTogetherRelayHint => 'https://meu-relay.exemplo.com.br';
|
||||||
|
@override String get watchTogetherRelayInvalid => '';
|
||||||
@override String get crashReporting => 'Relatório de Erros';
|
@override String get crashReporting => 'Relatório de Erros';
|
||||||
@override String get crashReportingDescription => 'Enviar relatórios de erros para ajudar a melhorar o app';
|
@override String get crashReportingDescription => 'Enviar relatórios de erros para ajudar a melhorar o app';
|
||||||
@override String get debugLogging => 'Log de Depuração';
|
@override String get debugLogging => 'Log de Depuração';
|
||||||
@@ -2303,6 +2304,7 @@ extension on TranslationsPt {
|
|||||||
'settings.watchTogetherRelay' => 'Relay do Assistir Juntos',
|
'settings.watchTogetherRelay' => 'Relay do Assistir Juntos',
|
||||||
'settings.watchTogetherRelayDescription' => 'Defina um relay personalizado. Todos devem usar o mesmo servidor.',
|
'settings.watchTogetherRelayDescription' => 'Defina um relay personalizado. Todos devem usar o mesmo servidor.',
|
||||||
'settings.watchTogetherRelayHint' => 'https://meu-relay.exemplo.com.br',
|
'settings.watchTogetherRelayHint' => 'https://meu-relay.exemplo.com.br',
|
||||||
|
'settings.watchTogetherRelayInvalid' => '',
|
||||||
'settings.crashReporting' => 'Relatório de Erros',
|
'settings.crashReporting' => 'Relatório de Erros',
|
||||||
'settings.crashReportingDescription' => 'Enviar relatórios de erros para ajudar a melhorar o app',
|
'settings.crashReportingDescription' => 'Enviar relatórios de erros para ajudar a melhorar o app',
|
||||||
'settings.debugLogging' => 'Log de Depuração',
|
'settings.debugLogging' => 'Log de Depuração',
|
||||||
@@ -2644,9 +2646,9 @@ extension on TranslationsPt {
|
|||||||
'messages.markedAsUnwatchedOffline' => 'Marcado como não assistido (será sincronizado quando online)',
|
'messages.markedAsUnwatchedOffline' => 'Marcado como não assistido (será sincronizado quando online)',
|
||||||
'messages.autoRemovedWatchedDownload' => ({required Object title}) => 'Removido automaticamente: ${title}',
|
'messages.autoRemovedWatchedDownload' => ({required Object title}) => 'Removido automaticamente: ${title}',
|
||||||
'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('pt'))(n, one: 'Removido automaticamente ${n} download assistido', other: 'Removidos automaticamente ${n} downloads assistidos', ),
|
'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('pt'))(n, one: 'Removido automaticamente ${n} download assistido', other: 'Removidos automaticamente ${n} downloads assistidos', ),
|
||||||
'messages.removedFromContinueWatching' => 'Removido de Continuar Assistindo',
|
|
||||||
_ => null,
|
_ => null,
|
||||||
} ?? switch (path) {
|
} ?? switch (path) {
|
||||||
|
'messages.removedFromContinueWatching' => 'Removido de Continuar Assistindo',
|
||||||
'messages.errorLoading' => ({required Object error}) => 'Erro: ${error}',
|
'messages.errorLoading' => ({required Object error}) => 'Erro: ${error}',
|
||||||
'messages.streamInterrupted' => 'A transmissão foi interrompida. Toque em reproduzir ou avance para tentar novamente.',
|
'messages.streamInterrupted' => 'A transmissão foi interrompida. Toque em reproduzir ou avance para tentar novamente.',
|
||||||
'messages.liveStreamInterrupted' => 'A transmissão ao vivo foi interrompida. Toque em reproduzir para tentar novamente.',
|
'messages.liveStreamInterrupted' => 'A transmissão ao vivo foi interrompida. Toque em reproduzir para tentar novamente.',
|
||||||
@@ -3158,9 +3160,9 @@ extension on TranslationsPt {
|
|||||||
'watchTogether.participantSeeked' => ({required Object name}) => '${name} avançou',
|
'watchTogether.participantSeeked' => ({required Object name}) => '${name} avançou',
|
||||||
'watchTogether.participantBuffering' => ({required Object name}) => '${name} está carregando',
|
'watchTogether.participantBuffering' => ({required Object name}) => '${name} está carregando',
|
||||||
'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} está em uma versão mais antiga do aplicativo — sincronização indisponível',
|
'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} está em uma versão mais antiga do aplicativo — sincronização indisponível',
|
||||||
'watchTogether.resumingWithout' => ({required Object name}) => 'Retomando sem ${name}',
|
|
||||||
_ => null,
|
_ => null,
|
||||||
} ?? switch (path) {
|
} ?? switch (path) {
|
||||||
|
'watchTogether.resumingWithout' => ({required Object name}) => 'Retomando sem ${name}',
|
||||||
'watchTogether.waitingForParticipants' => 'Aguardando outros carregarem...',
|
'watchTogether.waitingForParticipants' => 'Aguardando outros carregarem...',
|
||||||
'watchTogether.waitingForName' => ({required Object name}) => 'Aguardando ${name}...',
|
'watchTogether.waitingForName' => ({required Object name}) => 'Aguardando ${name}...',
|
||||||
'watchTogether.recentRooms' => 'Salas recentes',
|
'watchTogether.recentRooms' => 'Salas recentes',
|
||||||
|
|||||||
@@ -311,6 +311,7 @@ class _TranslationsSettingsRu extends TranslationsSettingsEn {
|
|||||||
@override String get watchTogetherRelay => 'Relay совместного просмотра';
|
@override String get watchTogetherRelay => 'Relay совместного просмотра';
|
||||||
@override String get watchTogetherRelayDescription => 'Задайте свой relay. Все должны использовать один сервер.';
|
@override String get watchTogetherRelayDescription => 'Задайте свой relay. Все должны использовать один сервер.';
|
||||||
@override String get watchTogetherRelayHint => 'https://my-relay.example.com';
|
@override String get watchTogetherRelayHint => 'https://my-relay.example.com';
|
||||||
|
@override String get watchTogetherRelayInvalid => '';
|
||||||
@override String get crashReporting => 'Отчёты об ошибках';
|
@override String get crashReporting => 'Отчёты об ошибках';
|
||||||
@override String get crashReportingDescription => 'Отправлять отчёты об ошибках для улучшения приложения';
|
@override String get crashReportingDescription => 'Отправлять отчёты об ошибках для улучшения приложения';
|
||||||
@override String get debugLogging => 'Журнал отладки';
|
@override String get debugLogging => 'Журнал отладки';
|
||||||
@@ -2309,6 +2310,7 @@ extension on TranslationsRu {
|
|||||||
'settings.watchTogetherRelay' => 'Relay совместного просмотра',
|
'settings.watchTogetherRelay' => 'Relay совместного просмотра',
|
||||||
'settings.watchTogetherRelayDescription' => 'Задайте свой relay. Все должны использовать один сервер.',
|
'settings.watchTogetherRelayDescription' => 'Задайте свой relay. Все должны использовать один сервер.',
|
||||||
'settings.watchTogetherRelayHint' => 'https://my-relay.example.com',
|
'settings.watchTogetherRelayHint' => 'https://my-relay.example.com',
|
||||||
|
'settings.watchTogetherRelayInvalid' => '',
|
||||||
'settings.crashReporting' => 'Отчёты об ошибках',
|
'settings.crashReporting' => 'Отчёты об ошибках',
|
||||||
'settings.crashReportingDescription' => 'Отправлять отчёты об ошибках для улучшения приложения',
|
'settings.crashReportingDescription' => 'Отправлять отчёты об ошибках для улучшения приложения',
|
||||||
'settings.debugLogging' => 'Журнал отладки',
|
'settings.debugLogging' => 'Журнал отладки',
|
||||||
@@ -2650,9 +2652,9 @@ extension on TranslationsRu {
|
|||||||
'messages.markedAsUnwatchedOffline' => 'Отмечено как непросмотренное (синхронизируется при подключении)',
|
'messages.markedAsUnwatchedOffline' => 'Отмечено как непросмотренное (синхронизируется при подключении)',
|
||||||
'messages.autoRemovedWatchedDownload' => ({required Object title}) => 'Автоудалено: ${title}',
|
'messages.autoRemovedWatchedDownload' => ({required Object title}) => 'Автоудалено: ${title}',
|
||||||
'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('ru'))(n, one: 'Автоматически удалена ${n} просмотренная загрузка', few: 'Автоматически удалены ${n} просмотренные загрузки', many: 'Автоматически удалено ${n} просмотренных загрузок', other: 'Автоматически удалено ${n} просмотренной загрузки', ),
|
'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('ru'))(n, one: 'Автоматически удалена ${n} просмотренная загрузка', few: 'Автоматически удалены ${n} просмотренные загрузки', many: 'Автоматически удалено ${n} просмотренных загрузок', other: 'Автоматически удалено ${n} просмотренной загрузки', ),
|
||||||
'messages.removedFromContinueWatching' => 'Удалено из «Продолжить просмотр»',
|
|
||||||
_ => null,
|
_ => null,
|
||||||
} ?? switch (path) {
|
} ?? switch (path) {
|
||||||
|
'messages.removedFromContinueWatching' => 'Удалено из «Продолжить просмотр»',
|
||||||
'messages.errorLoading' => ({required Object error}) => 'Ошибка: ${error}',
|
'messages.errorLoading' => ({required Object error}) => 'Ошибка: ${error}',
|
||||||
'messages.streamInterrupted' => 'Поток прервался. Нажмите «Воспроизвести» или перемотайте, чтобы повторить попытку.',
|
'messages.streamInterrupted' => 'Поток прервался. Нажмите «Воспроизвести» или перемотайте, чтобы повторить попытку.',
|
||||||
'messages.liveStreamInterrupted' => 'Прямая трансляция прервалась. Нажмите «Воспроизвести», чтобы повторить попытку.',
|
'messages.liveStreamInterrupted' => 'Прямая трансляция прервалась. Нажмите «Воспроизвести», чтобы повторить попытку.',
|
||||||
@@ -3164,9 +3166,9 @@ extension on TranslationsRu {
|
|||||||
'watchTogether.participantSeeked' => ({required Object name}) => '${name} перемотал',
|
'watchTogether.participantSeeked' => ({required Object name}) => '${name} перемотал',
|
||||||
'watchTogether.participantBuffering' => ({required Object name}) => '${name} буферизует',
|
'watchTogether.participantBuffering' => ({required Object name}) => '${name} буферизует',
|
||||||
'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} использует старую версию приложения — синхронизация недоступна',
|
'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} использует старую версию приложения — синхронизация недоступна',
|
||||||
'watchTogether.resumingWithout' => ({required Object name}) => 'Возобновление без ${name}',
|
|
||||||
_ => null,
|
_ => null,
|
||||||
} ?? switch (path) {
|
} ?? switch (path) {
|
||||||
|
'watchTogether.resumingWithout' => ({required Object name}) => 'Возобновление без ${name}',
|
||||||
'watchTogether.waitingForParticipants' => 'Ожидание загрузки у других...',
|
'watchTogether.waitingForParticipants' => 'Ожидание загрузки у других...',
|
||||||
'watchTogether.waitingForName' => ({required Object name}) => 'Ожидание ${name}...',
|
'watchTogether.waitingForName' => ({required Object name}) => 'Ожидание ${name}...',
|
||||||
'watchTogether.recentRooms' => 'Недавние комнаты',
|
'watchTogether.recentRooms' => 'Недавние комнаты',
|
||||||
|
|||||||
@@ -311,6 +311,7 @@ class _TranslationsSettingsSv extends TranslationsSettingsEn {
|
|||||||
@override String get watchTogetherRelay => 'Titta Tillsammans-relay';
|
@override String get watchTogetherRelay => 'Titta Tillsammans-relay';
|
||||||
@override String get watchTogetherRelayDescription => 'Ange en anpassad relay. Alla måste använda samma server.';
|
@override String get watchTogetherRelayDescription => 'Ange en anpassad relay. Alla måste använda samma server.';
|
||||||
@override String get watchTogetherRelayHint => 'https://min-relay.exempel.se';
|
@override String get watchTogetherRelayHint => 'https://min-relay.exempel.se';
|
||||||
|
@override String get watchTogetherRelayInvalid => '';
|
||||||
@override String get crashReporting => 'Kraschrapportering';
|
@override String get crashReporting => 'Kraschrapportering';
|
||||||
@override String get crashReportingDescription => 'Skicka kraschrapporter för att förbättra appen';
|
@override String get crashReportingDescription => 'Skicka kraschrapporter för att förbättra appen';
|
||||||
@override String get debugLogging => 'Felsökningsloggning';
|
@override String get debugLogging => 'Felsökningsloggning';
|
||||||
@@ -2303,6 +2304,7 @@ extension on TranslationsSv {
|
|||||||
'settings.watchTogetherRelay' => 'Titta Tillsammans-relay',
|
'settings.watchTogetherRelay' => 'Titta Tillsammans-relay',
|
||||||
'settings.watchTogetherRelayDescription' => 'Ange en anpassad relay. Alla måste använda samma server.',
|
'settings.watchTogetherRelayDescription' => 'Ange en anpassad relay. Alla måste använda samma server.',
|
||||||
'settings.watchTogetherRelayHint' => 'https://min-relay.exempel.se',
|
'settings.watchTogetherRelayHint' => 'https://min-relay.exempel.se',
|
||||||
|
'settings.watchTogetherRelayInvalid' => '',
|
||||||
'settings.crashReporting' => 'Kraschrapportering',
|
'settings.crashReporting' => 'Kraschrapportering',
|
||||||
'settings.crashReportingDescription' => 'Skicka kraschrapporter för att förbättra appen',
|
'settings.crashReportingDescription' => 'Skicka kraschrapporter för att förbättra appen',
|
||||||
'settings.debugLogging' => 'Felsökningsloggning',
|
'settings.debugLogging' => 'Felsökningsloggning',
|
||||||
@@ -2644,9 +2646,9 @@ extension on TranslationsSv {
|
|||||||
'messages.markedAsUnwatchedOffline' => 'Markerad som osedd (synkroniseras när online)',
|
'messages.markedAsUnwatchedOffline' => 'Markerad som osedd (synkroniseras när online)',
|
||||||
'messages.autoRemovedWatchedDownload' => ({required Object title}) => 'Automatiskt borttagen: ${title}',
|
'messages.autoRemovedWatchedDownload' => ({required Object title}) => 'Automatiskt borttagen: ${title}',
|
||||||
'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('sv'))(n, one: 'Tog automatiskt bort ${n} sedd nedladdning', other: 'Tog automatiskt bort ${n} sedda nedladdningar', ),
|
'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('sv'))(n, one: 'Tog automatiskt bort ${n} sedd nedladdning', other: 'Tog automatiskt bort ${n} sedda nedladdningar', ),
|
||||||
'messages.removedFromContinueWatching' => 'Borttagen från Fortsätt titta',
|
|
||||||
_ => null,
|
_ => null,
|
||||||
} ?? switch (path) {
|
} ?? switch (path) {
|
||||||
|
'messages.removedFromContinueWatching' => 'Borttagen från Fortsätt titta',
|
||||||
'messages.errorLoading' => ({required Object error}) => 'Fel: ${error}',
|
'messages.errorLoading' => ({required Object error}) => 'Fel: ${error}',
|
||||||
'messages.streamInterrupted' => 'Uppspelningen avbröts. Tryck på play eller spola för att försöka igen.',
|
'messages.streamInterrupted' => 'Uppspelningen avbröts. Tryck på play eller spola för att försöka igen.',
|
||||||
'messages.liveStreamInterrupted' => 'Livestreamen avbröts. Tryck på play för att försöka igen.',
|
'messages.liveStreamInterrupted' => 'Livestreamen avbröts. Tryck på play för att försöka igen.',
|
||||||
@@ -3158,9 +3160,9 @@ extension on TranslationsSv {
|
|||||||
'watchTogether.participantSeeked' => ({required Object name}) => '${name} spolade',
|
'watchTogether.participantSeeked' => ({required Object name}) => '${name} spolade',
|
||||||
'watchTogether.participantBuffering' => ({required Object name}) => '${name} buffrar',
|
'watchTogether.participantBuffering' => ({required Object name}) => '${name} buffrar',
|
||||||
'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} använder en äldre appversion — synkronisering är inte tillgänglig',
|
'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} använder en äldre appversion — synkronisering är inte tillgänglig',
|
||||||
'watchTogether.resumingWithout' => ({required Object name}) => 'Återupptar utan ${name}',
|
|
||||||
_ => null,
|
_ => null,
|
||||||
} ?? switch (path) {
|
} ?? switch (path) {
|
||||||
|
'watchTogether.resumingWithout' => ({required Object name}) => 'Återupptar utan ${name}',
|
||||||
'watchTogether.waitingForParticipants' => 'Väntar på att andra laddar...',
|
'watchTogether.waitingForParticipants' => 'Väntar på att andra laddar...',
|
||||||
'watchTogether.waitingForName' => ({required Object name}) => 'Väntar på ${name}...',
|
'watchTogether.waitingForName' => ({required Object name}) => 'Väntar på ${name}...',
|
||||||
'watchTogether.recentRooms' => 'Senaste rum',
|
'watchTogether.recentRooms' => 'Senaste rum',
|
||||||
|
|||||||
@@ -311,6 +311,7 @@ class _TranslationsSettingsZh extends TranslationsSettingsEn {
|
|||||||
@override String get watchTogetherRelay => '一起看中继服务器';
|
@override String get watchTogetherRelay => '一起看中继服务器';
|
||||||
@override String get watchTogetherRelayDescription => '设置自定义中继。所有人必须使用同一服务器。';
|
@override String get watchTogetherRelayDescription => '设置自定义中继。所有人必须使用同一服务器。';
|
||||||
@override String get watchTogetherRelayHint => 'https://my-relay.example.com';
|
@override String get watchTogetherRelayHint => 'https://my-relay.example.com';
|
||||||
|
@override String get watchTogetherRelayInvalid => '';
|
||||||
@override String get crashReporting => '崩溃报告';
|
@override String get crashReporting => '崩溃报告';
|
||||||
@override String get crashReportingDescription => '发送崩溃报告以帮助改进应用';
|
@override String get crashReportingDescription => '发送崩溃报告以帮助改进应用';
|
||||||
@override String get debugLogging => '调试日志';
|
@override String get debugLogging => '调试日志';
|
||||||
@@ -2300,6 +2301,7 @@ extension on TranslationsZh {
|
|||||||
'settings.watchTogetherRelay' => '一起看中继服务器',
|
'settings.watchTogetherRelay' => '一起看中继服务器',
|
||||||
'settings.watchTogetherRelayDescription' => '设置自定义中继。所有人必须使用同一服务器。',
|
'settings.watchTogetherRelayDescription' => '设置自定义中继。所有人必须使用同一服务器。',
|
||||||
'settings.watchTogetherRelayHint' => 'https://my-relay.example.com',
|
'settings.watchTogetherRelayHint' => 'https://my-relay.example.com',
|
||||||
|
'settings.watchTogetherRelayInvalid' => '',
|
||||||
'settings.crashReporting' => '崩溃报告',
|
'settings.crashReporting' => '崩溃报告',
|
||||||
'settings.crashReportingDescription' => '发送崩溃报告以帮助改进应用',
|
'settings.crashReportingDescription' => '发送崩溃报告以帮助改进应用',
|
||||||
'settings.debugLogging' => '调试日志',
|
'settings.debugLogging' => '调试日志',
|
||||||
@@ -2641,9 +2643,9 @@ extension on TranslationsZh {
|
|||||||
'messages.markedAsUnwatchedOffline' => '已标记为未观看 (将在联网时同步)',
|
'messages.markedAsUnwatchedOffline' => '已标记为未观看 (将在联网时同步)',
|
||||||
'messages.autoRemovedWatchedDownload' => ({required Object title}) => '已自动移除: ${title}',
|
'messages.autoRemovedWatchedDownload' => ({required Object title}) => '已自动移除: ${title}',
|
||||||
'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('zh'))(n, other: '已自动移除 ${n} 个看过的下载', ),
|
'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('zh'))(n, other: '已自动移除 ${n} 个看过的下载', ),
|
||||||
'messages.removedFromContinueWatching' => '已从继续观看中移除',
|
|
||||||
_ => null,
|
_ => null,
|
||||||
} ?? switch (path) {
|
} ?? switch (path) {
|
||||||
|
'messages.removedFromContinueWatching' => '已从继续观看中移除',
|
||||||
'messages.errorLoading' => ({required Object error}) => '错误: ${error}',
|
'messages.errorLoading' => ({required Object error}) => '错误: ${error}',
|
||||||
'messages.streamInterrupted' => '视频流已中断。按播放键或拖动进度条重试。',
|
'messages.streamInterrupted' => '视频流已中断。按播放键或拖动进度条重试。',
|
||||||
'messages.liveStreamInterrupted' => '直播流已中断。按播放键重试。',
|
'messages.liveStreamInterrupted' => '直播流已中断。按播放键重试。',
|
||||||
@@ -3155,9 +3157,9 @@ extension on TranslationsZh {
|
|||||||
'watchTogether.participantSeeked' => ({required Object name}) => '${name} 跳转了',
|
'watchTogether.participantSeeked' => ({required Object name}) => '${name} 跳转了',
|
||||||
'watchTogether.participantBuffering' => ({required Object name}) => '${name} 正在缓冲',
|
'watchTogether.participantBuffering' => ({required Object name}) => '${name} 正在缓冲',
|
||||||
'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} 正在使用较旧版本的应用,无法同步',
|
'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} 正在使用较旧版本的应用,无法同步',
|
||||||
'watchTogether.resumingWithout' => ({required Object name}) => '不等待 ${name},继续播放',
|
|
||||||
_ => null,
|
_ => null,
|
||||||
} ?? switch (path) {
|
} ?? switch (path) {
|
||||||
|
'watchTogether.resumingWithout' => ({required Object name}) => '不等待 ${name},继续播放',
|
||||||
'watchTogether.waitingForParticipants' => '等待其他人加载...',
|
'watchTogether.waitingForParticipants' => '等待其他人加载...',
|
||||||
'watchTogether.waitingForName' => ({required Object name}) => '正在等待 ${name}...',
|
'watchTogether.waitingForName' => ({required Object name}) => '正在等待 ${name}...',
|
||||||
'watchTogether.recentRooms' => '最近的房间',
|
'watchTogether.recentRooms' => '最近的房间',
|
||||||
|
|||||||
@@ -180,6 +180,7 @@
|
|||||||
"watchTogetherRelay": "Titta Tillsammans-relay",
|
"watchTogetherRelay": "Titta Tillsammans-relay",
|
||||||
"watchTogetherRelayDescription": "Ange en anpassad relay. Alla måste använda samma server.",
|
"watchTogetherRelayDescription": "Ange en anpassad relay. Alla måste använda samma server.",
|
||||||
"watchTogetherRelayHint": "https://min-relay.exempel.se",
|
"watchTogetherRelayHint": "https://min-relay.exempel.se",
|
||||||
|
"watchTogetherRelayInvalid": "",
|
||||||
"crashReporting": "Kraschrapportering",
|
"crashReporting": "Kraschrapportering",
|
||||||
"crashReportingDescription": "Skicka kraschrapporter för att förbättra appen",
|
"crashReportingDescription": "Skicka kraschrapporter för att förbättra appen",
|
||||||
"debugLogging": "Felsökningsloggning",
|
"debugLogging": "Felsökningsloggning",
|
||||||
|
|||||||
@@ -180,6 +180,7 @@
|
|||||||
"watchTogetherRelay": "一起看中继服务器",
|
"watchTogetherRelay": "一起看中继服务器",
|
||||||
"watchTogetherRelayDescription": "设置自定义中继。所有人必须使用同一服务器。",
|
"watchTogetherRelayDescription": "设置自定义中继。所有人必须使用同一服务器。",
|
||||||
"watchTogetherRelayHint": "https://my-relay.example.com",
|
"watchTogetherRelayHint": "https://my-relay.example.com",
|
||||||
|
"watchTogetherRelayInvalid": "",
|
||||||
"crashReporting": "崩溃报告",
|
"crashReporting": "崩溃报告",
|
||||||
"crashReportingDescription": "发送崩溃报告以帮助改进应用",
|
"crashReportingDescription": "发送崩溃报告以帮助改进应用",
|
||||||
"debugLogging": "调试日志",
|
"debugLogging": "调试日志",
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ export '../services/companion_remote/lan_discovery_service.dart' show Discovered
|
|||||||
|
|
||||||
typedef CommandReceivedCallback = void Function(RemoteCommand command);
|
typedef CommandReceivedCallback = void Function(RemoteCommand command);
|
||||||
typedef PlexHomeResolver = Future<PlexHome?> Function(String connectionId);
|
typedef PlexHomeResolver = Future<PlexHome?> Function(String connectionId);
|
||||||
|
typedef CompanionRemotePeerServiceFactory = CompanionRemotePeerService Function();
|
||||||
|
typedef LanDiscoveryServiceFactory = LanDiscoveryService Function();
|
||||||
|
|
||||||
String _localizedRemoteError(Object error, String Function(String details) fallback) {
|
String _localizedRemoteError(Object error, String Function(String details) fallback) {
|
||||||
if (error is RemotePeerError) return error.message;
|
if (error is RemotePeerError) return error.message;
|
||||||
@@ -32,8 +34,24 @@ String _localizedRemoteError(Object error, String Function(String details) fallb
|
|||||||
}
|
}
|
||||||
|
|
||||||
class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin {
|
class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin {
|
||||||
|
CompanionRemoteProvider() : this._(CompanionRemotePeerService.new, LanDiscoveryService.new);
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
CompanionRemoteProvider.forTesting({
|
||||||
|
required CompanionRemotePeerServiceFactory peerServiceFactory,
|
||||||
|
LanDiscoveryServiceFactory discoveryServiceFactory = LanDiscoveryService.new,
|
||||||
|
}) : this._(peerServiceFactory, discoveryServiceFactory);
|
||||||
|
|
||||||
|
CompanionRemoteProvider._(this._peerServiceFactory, this._discoveryServiceFactory) {
|
||||||
|
_initializeDeviceInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
final CompanionRemotePeerServiceFactory _peerServiceFactory;
|
||||||
|
final LanDiscoveryServiceFactory _discoveryServiceFactory;
|
||||||
RemoteSession? _session;
|
RemoteSession? _session;
|
||||||
CompanionRemotePeerService? _peerService;
|
CompanionRemotePeerService? _peerService;
|
||||||
|
CompanionRemotePeerService? _pendingRemotePeer;
|
||||||
|
final Expando<Future<void>> _peerDisposals = Expando<Future<void>>('companion remote peer disposal');
|
||||||
LanDiscoveryService? _discoveryService;
|
LanDiscoveryService? _discoveryService;
|
||||||
String _deviceName = t.companionRemote.unknownDevice;
|
String _deviceName = t.companionRemote.unknownDevice;
|
||||||
String _platform = 'unknown';
|
String _platform = 'unknown';
|
||||||
@@ -44,7 +62,11 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin
|
|||||||
Timer? _reconnectTimer;
|
Timer? _reconnectTimer;
|
||||||
int _reconnectAttempts = 0;
|
int _reconnectAttempts = 0;
|
||||||
Future<void>? _activeReconnect;
|
Future<void>? _activeReconnect;
|
||||||
bool _intentionalDisconnect = false;
|
int? _activeReconnectGeneration;
|
||||||
|
int _remoteGeneration = 0;
|
||||||
|
final Expando<int> _intentionalDisconnectGeneration = Expando<int>(
|
||||||
|
'companion remote intentional disconnect generation',
|
||||||
|
);
|
||||||
|
|
||||||
// Reconnection context (only hostAddresses and hostClientId are connection-specific)
|
// Reconnection context (only hostAddresses and hostClientId are connection-specific)
|
||||||
List<String>? _lastHostAddresses;
|
List<String>? _lastHostAddresses;
|
||||||
@@ -90,10 +112,6 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin
|
|||||||
bool get isPlayerActive => _isPlayerActive;
|
bool get isPlayerActive => _isPlayerActive;
|
||||||
bool get isHostServerRunning => _peerService?.isServerRunning ?? false;
|
bool get isHostServerRunning => _peerService?.isServerRunning ?? false;
|
||||||
|
|
||||||
CompanionRemoteProvider() {
|
|
||||||
_initializeDeviceInfo();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _initializeDeviceInfo() async {
|
Future<void> _initializeDeviceInfo() async {
|
||||||
final identity = await DeviceIdentityService.resolve();
|
final identity = await DeviceIdentityService.resolve();
|
||||||
_deviceName = identity.deviceName ?? t.companionRemote.unknownDevice;
|
_deviceName = identity.deviceName ?? t.companionRemote.unknownDevice;
|
||||||
@@ -420,16 +438,110 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin
|
|||||||
_cryptoProfileId = null;
|
_cryptoProfileId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _markIntentionalDisconnect(CompanionRemotePeerService? peer, int generation) {
|
||||||
|
if (peer != null) {
|
||||||
|
_intentionalDisconnectGeneration[peer] = generation;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clearIntentionalDisconnect(CompanionRemotePeerService? peer, int generation) {
|
||||||
|
if (peer != null && _intentionalDisconnectGeneration[peer] == generation) {
|
||||||
|
_intentionalDisconnectGeneration[peer] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isIntentionalDisconnect(CompanionRemotePeerService peer, int generation) {
|
||||||
|
return _intentionalDisconnectGeneration[peer] == generation;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _ownsPeer(CompanionRemotePeerService peer, int generation) {
|
||||||
|
return !isDisposed &&
|
||||||
|
generation == _remoteGeneration &&
|
||||||
|
(identical(_peerService, peer) || identical(_pendingRemotePeer, peer));
|
||||||
|
}
|
||||||
|
|
||||||
|
({CompanionRemotePeerService? current, CompanionRemotePeerService? pending}) _invalidateRemoteLifecycle() {
|
||||||
|
_remoteGeneration++;
|
||||||
|
_reconnectTimer?.cancel();
|
||||||
|
_reconnectTimer = null;
|
||||||
|
|
||||||
|
final pending = _pendingRemotePeer;
|
||||||
|
final current = _session?.isRemote == true ? _peerService : null;
|
||||||
|
_pendingRemotePeer = null;
|
||||||
|
if (identical(_peerService, current)) {
|
||||||
|
_peerService = null;
|
||||||
|
}
|
||||||
|
if (current != null || pending != null) {
|
||||||
|
_cleanupSubscriptions();
|
||||||
|
}
|
||||||
|
return (current: current, pending: pending);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _disposePeerOnce(CompanionRemotePeerService peer) {
|
||||||
|
final existing = _peerDisposals[peer];
|
||||||
|
if (existing != null) return existing;
|
||||||
|
|
||||||
|
final disposal = () async {
|
||||||
|
try {
|
||||||
|
await peer.dispose();
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
appLogger.d('CompanionRemote: Peer cleanup ignored', error: error, stackTrace: stackTrace);
|
||||||
|
}
|
||||||
|
}();
|
||||||
|
_peerDisposals[peer] = disposal;
|
||||||
|
return disposal;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _disposeDetachedPeers(
|
||||||
|
({CompanionRemotePeerService? current, CompanionRemotePeerService? pending}) peers,
|
||||||
|
) async {
|
||||||
|
final current = peers.current;
|
||||||
|
final pending = peers.pending;
|
||||||
|
if (current != null) {
|
||||||
|
await _disposePeerOnce(current);
|
||||||
|
}
|
||||||
|
if (pending != null && !identical(pending, current)) {
|
||||||
|
await _disposePeerOnce(pending);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_RemoteConnectRequest _beginRemoteConnectRequest() {
|
||||||
|
final wasHost = isHost || isHostServerRunning;
|
||||||
|
final peers = _invalidateRemoteLifecycle();
|
||||||
|
_reconnectAttempts = 0;
|
||||||
|
_session = null;
|
||||||
|
_isPlayerActive = false;
|
||||||
|
safeNotifyListeners();
|
||||||
|
return _RemoteConnectRequest(
|
||||||
|
generation: _remoteGeneration,
|
||||||
|
wasHost: wasHost,
|
||||||
|
current: peers.current,
|
||||||
|
pending: peers.pending,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _prepareRemoteConnect(_RemoteConnectRequest request) async {
|
||||||
|
await _disposeDetachedPeers((current: request.current, pending: request.pending));
|
||||||
|
if (request.wasHost) {
|
||||||
|
await _serializeLifecycle(_stopHostServerLocked);
|
||||||
|
} else {
|
||||||
|
stopDiscovery();
|
||||||
|
}
|
||||||
|
return !isDisposed && request.generation == _remoteGeneration;
|
||||||
|
}
|
||||||
|
|
||||||
/// Fully tear down network/session state and forget derived crypto material.
|
/// Fully tear down network/session state and forget derived crypto material.
|
||||||
/// Used by logout so an app-level provider surviving route replacement does
|
/// Used by logout so an app-level provider surviving route replacement does
|
||||||
/// not keep broadcasting with the previous Plex Home identity.
|
/// not keep broadcasting with the previous Plex Home identity.
|
||||||
Future<void> resetForLogout() {
|
Future<void> resetForLogout() {
|
||||||
|
final detachedPeers = _invalidateRemoteLifecycle();
|
||||||
|
_reconnectAttempts = 0;
|
||||||
|
_lastHostAddresses = null;
|
||||||
|
_lastHostClientId = null;
|
||||||
|
_lastAuthContextId = null;
|
||||||
|
|
||||||
return _serializeLifecycle(() async {
|
return _serializeLifecycle(() async {
|
||||||
_reconnectTimer?.cancel();
|
await _disposeDetachedPeers(detachedPeers);
|
||||||
_reconnectAttempts = 0;
|
|
||||||
_lastHostAddresses = null;
|
|
||||||
_lastHostClientId = null;
|
|
||||||
_lastAuthContextId = null;
|
|
||||||
await _stopHostServerLocked();
|
await _stopHostServerLocked();
|
||||||
stopDiscovery();
|
stopDiscovery();
|
||||||
_clearCryptoContext();
|
_clearCryptoContext();
|
||||||
@@ -450,6 +562,12 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin
|
|||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
List<String> get debugCryptoConnectionIds => _authContexts.map((context) => context.connectionId).toList();
|
List<String> get debugCryptoConnectionIds => _authContexts.map((context) => context.connectionId).toList();
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
bool get debugIsDiscoveryBroadcasting => _discoveryService?.isBroadcasting ?? false;
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
bool get debugIsDiscoveryListening => _discoveryService?.isListening ?? false;
|
||||||
|
|
||||||
Future<void> startHostServer() => _serializeLifecycle(_startHostServerLocked);
|
Future<void> startHostServer() => _serializeLifecycle(_startHostServerLocked);
|
||||||
|
|
||||||
Future<void> _startHostServerLocked() async {
|
Future<void> _startHostServerLocked() async {
|
||||||
@@ -461,8 +579,8 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin
|
|||||||
|
|
||||||
appLogger.d('CompanionRemote: Starting host server');
|
appLogger.d('CompanionRemote: Starting host server');
|
||||||
|
|
||||||
_peerService ??= CompanionRemotePeerService();
|
final peer = _peerService ??= _peerServiceFactory();
|
||||||
_setupPeerServiceListeners();
|
_setupPeerServiceListeners(peer, _remoteGeneration);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final contexts = List<RemoteAuthContext>.unmodifiable(_authContexts);
|
final contexts = List<RemoteAuthContext>.unmodifiable(_authContexts);
|
||||||
@@ -476,7 +594,7 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin
|
|||||||
safeNotifyListeners();
|
safeNotifyListeners();
|
||||||
|
|
||||||
// Start LAN discovery broadcasting
|
// Start LAN discovery broadcasting
|
||||||
_discoveryService ??= LanDiscoveryService();
|
_discoveryService ??= _discoveryServiceFactory();
|
||||||
final localIps = result.addresses.map((a) => a.split(':').first).toList();
|
final localIps = result.addresses.map((a) => a.split(':').first).toList();
|
||||||
await _discoveryService!.startBroadcastingForContexts(
|
await _discoveryService!.startBroadcastingForContexts(
|
||||||
contexts: contexts,
|
contexts: contexts,
|
||||||
@@ -503,19 +621,32 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin
|
|||||||
Future<void> stopHostServer() => _serializeLifecycle(_stopHostServerLocked);
|
Future<void> stopHostServer() => _serializeLifecycle(_stopHostServerLocked);
|
||||||
|
|
||||||
Future<void> _stopHostServerLocked() async {
|
Future<void> _stopHostServerLocked() async {
|
||||||
_intentionalDisconnect = true;
|
final stopGeneration = _remoteGeneration;
|
||||||
await _discoveryService?.stopBroadcasting();
|
final peer = _peerService;
|
||||||
|
_markIntentionalDisconnect(peer, stopGeneration);
|
||||||
|
|
||||||
if (_peerService != null) {
|
try {
|
||||||
await _peerService!.disconnect();
|
await _discoveryService?.stopBroadcasting();
|
||||||
_peerService = null;
|
_discoveryService?.stopListening();
|
||||||
|
|
||||||
|
if (identical(_peerService, peer)) {
|
||||||
|
_peerService = null;
|
||||||
|
_cleanupSubscriptions();
|
||||||
|
}
|
||||||
|
if (peer != null) {
|
||||||
|
await _disposePeerOnce(peer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A newer remote request may start while the stopped peer's asynchronous
|
||||||
|
// disposal is settling. Never let the older stop erase that replacement.
|
||||||
|
if (_remoteGeneration == stopGeneration) {
|
||||||
|
_session = null;
|
||||||
|
_isPlayerActive = false;
|
||||||
|
}
|
||||||
|
safeNotifyListeners();
|
||||||
|
} finally {
|
||||||
|
_clearIntentionalDisconnect(peer, stopGeneration);
|
||||||
}
|
}
|
||||||
_cleanupSubscriptions();
|
|
||||||
|
|
||||||
_session = null;
|
|
||||||
_isPlayerActive = false;
|
|
||||||
_intentionalDisconnect = false;
|
|
||||||
safeNotifyListeners();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Stream<List<DiscoveredHost>>? discoverHosts() {
|
Stream<List<DiscoveredHost>>? discoverHosts() {
|
||||||
@@ -524,7 +655,7 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
_discoveryService ??= LanDiscoveryService();
|
_discoveryService ??= _discoveryServiceFactory();
|
||||||
return _discoveryService!.startListeningForContexts(_authContexts);
|
return _discoveryService!.startListeningForContexts(_authContexts);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -543,26 +674,28 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin
|
|||||||
throw RemotePeerError(type: RemotePeerErrorType.authFailed, message: t.companionRemote.pairing.authFailed);
|
throw RemotePeerError(type: RemotePeerErrorType.authFailed, message: t.companionRemote.pairing.authFailed);
|
||||||
}
|
}
|
||||||
|
|
||||||
await leaveSession();
|
final request = _beginRemoteConnectRequest();
|
||||||
|
if (!await _prepareRemoteConnect(request)) return;
|
||||||
|
|
||||||
|
final generation = request.generation;
|
||||||
_lastHostAddresses = host.addresses;
|
_lastHostAddresses = host.addresses;
|
||||||
_lastHostClientId = host.clientId;
|
_lastHostClientId = host.clientId;
|
||||||
_lastAuthContextId = authContext.id;
|
_lastAuthContextId = authContext.id;
|
||||||
|
|
||||||
appLogger.d('CompanionRemote: Connecting to ${host.name} at ${host.addresses}');
|
appLogger.d('CompanionRemote: Connecting to ${host.name} at ${host.addresses}');
|
||||||
|
|
||||||
_peerService = CompanionRemotePeerService();
|
final candidate = _peerServiceFactory();
|
||||||
_setupPeerServiceListeners();
|
_pendingRemotePeer = candidate;
|
||||||
|
|
||||||
_session = RemoteSession(
|
_session = RemoteSession(
|
||||||
role: RemoteSessionRole.remote,
|
role: RemoteSessionRole.remote,
|
||||||
status: RemoteSessionStatus.connecting,
|
status: RemoteSessionStatus.connecting,
|
||||||
createdAt: DateTime.now(),
|
createdAt: DateTime.now(),
|
||||||
);
|
);
|
||||||
|
_setupPeerServiceListeners(candidate, generation);
|
||||||
safeNotifyListeners();
|
safeNotifyListeners();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final winner = await _peerService!.joinSessionRacingWithContexts(
|
final winner = await candidate.joinSessionRacingWithContexts(
|
||||||
_deviceName,
|
_deviceName,
|
||||||
_platform,
|
_platform,
|
||||||
host.addresses,
|
host.addresses,
|
||||||
@@ -570,18 +703,35 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin
|
|||||||
authContextId: authContext.id,
|
authContextId: authContext.id,
|
||||||
expectedHostClientId: host.clientId,
|
expectedHostClientId: host.clientId,
|
||||||
);
|
);
|
||||||
_lastHostAddresses = [winner];
|
if (!_ownsPeer(candidate, generation)) {
|
||||||
_lastAuthContextId = _peerService!.selectedAuthContextId ?? authContext.id;
|
await _disposePeerOnce(candidate);
|
||||||
_lastHostClientId = _peerService!.selectedHostClientId ?? host.clientId;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_pendingRemotePeer = null;
|
||||||
|
_peerService = candidate;
|
||||||
|
_lastHostAddresses = [winner];
|
||||||
|
_lastAuthContextId = candidate.selectedAuthContextId ?? authContext.id;
|
||||||
|
_lastHostClientId = candidate.selectedHostClientId ?? host.clientId;
|
||||||
_session = _session?.copyWith(status: RemoteSessionStatus.connected);
|
_session = _session?.copyWith(status: RemoteSessionStatus.connected);
|
||||||
safeNotifyListeners();
|
safeNotifyListeners();
|
||||||
appLogger.d('CompanionRemote: Connected to ${host.name} via $winner');
|
appLogger.d('CompanionRemote: Connected to ${host.name} via $winner');
|
||||||
} catch (e) {
|
} catch (error, stackTrace) {
|
||||||
appLogger.e('CompanionRemote: Failed to connect to host', error: e);
|
if (!_ownsPeer(candidate, generation)) {
|
||||||
|
await _disposePeerOnce(candidate);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_pendingRemotePeer = null;
|
||||||
|
_cleanupSubscriptions();
|
||||||
|
await _disposePeerOnce(candidate);
|
||||||
|
appLogger.e('CompanionRemote: Failed to connect to host', error: error, stackTrace: stackTrace);
|
||||||
_session = _session?.copyWith(
|
_session = _session?.copyWith(
|
||||||
status: RemoteSessionStatus.error,
|
status: RemoteSessionStatus.error,
|
||||||
errorMessage: _localizedRemoteError(e, (details) => t.companionRemote.pairing.failedToConnect(error: details)),
|
errorMessage: _localizedRemoteError(
|
||||||
|
error,
|
||||||
|
(details) => t.companionRemote.pairing.failedToConnect(error: details),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
safeNotifyListeners();
|
safeNotifyListeners();
|
||||||
rethrow;
|
rethrow;
|
||||||
@@ -594,49 +744,69 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin
|
|||||||
throw RemotePeerError(type: RemotePeerErrorType.authFailed, message: t.companionRemote.pairing.cryptoInitFailed);
|
throw RemotePeerError(type: RemotePeerErrorType.authFailed, message: t.companionRemote.pairing.cryptoInitFailed);
|
||||||
}
|
}
|
||||||
|
|
||||||
await leaveSession();
|
final request = _beginRemoteConnectRequest();
|
||||||
|
if (!await _prepareRemoteConnect(request)) return;
|
||||||
|
|
||||||
|
final generation = request.generation;
|
||||||
_lastHostAddresses = [hostAddress];
|
_lastHostAddresses = [hostAddress];
|
||||||
_lastHostClientId = '';
|
_lastHostClientId = '';
|
||||||
_lastAuthContextId = null;
|
_lastAuthContextId = null;
|
||||||
|
|
||||||
appLogger.d('CompanionRemote: Connecting to manual host $hostAddress');
|
appLogger.d('CompanionRemote: Connecting to manual host $hostAddress');
|
||||||
|
|
||||||
_peerService = CompanionRemotePeerService();
|
final candidate = _peerServiceFactory();
|
||||||
_setupPeerServiceListeners();
|
_pendingRemotePeer = candidate;
|
||||||
|
|
||||||
_session = RemoteSession(
|
_session = RemoteSession(
|
||||||
role: RemoteSessionRole.remote,
|
role: RemoteSessionRole.remote,
|
||||||
status: RemoteSessionStatus.connecting,
|
status: RemoteSessionStatus.connecting,
|
||||||
createdAt: DateTime.now(),
|
createdAt: DateTime.now(),
|
||||||
);
|
);
|
||||||
|
_setupPeerServiceListeners(candidate, generation);
|
||||||
safeNotifyListeners();
|
safeNotifyListeners();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await _peerService!.joinSessionWithContexts(_deviceName, _platform, hostAddress, _authContexts);
|
await candidate.joinSessionWithContexts(_deviceName, _platform, hostAddress, _authContexts);
|
||||||
_lastAuthContextId = _peerService!.selectedAuthContextId;
|
if (!_ownsPeer(candidate, generation)) {
|
||||||
_lastHostClientId = _peerService!.selectedHostClientId ?? '';
|
await _disposePeerOnce(candidate);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_pendingRemotePeer = null;
|
||||||
|
_peerService = candidate;
|
||||||
|
_lastAuthContextId = candidate.selectedAuthContextId;
|
||||||
|
_lastHostClientId = candidate.selectedHostClientId ?? '';
|
||||||
_session = _session?.copyWith(status: RemoteSessionStatus.connected);
|
_session = _session?.copyWith(status: RemoteSessionStatus.connected);
|
||||||
safeNotifyListeners();
|
safeNotifyListeners();
|
||||||
} catch (e) {
|
} catch (error, stackTrace) {
|
||||||
appLogger.e('CompanionRemote: Failed to connect to manual host', error: e);
|
if (!_ownsPeer(candidate, generation)) {
|
||||||
|
await _disposePeerOnce(candidate);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_pendingRemotePeer = null;
|
||||||
|
_cleanupSubscriptions();
|
||||||
|
await _disposePeerOnce(candidate);
|
||||||
|
appLogger.e('CompanionRemote: Failed to connect to manual host', error: error, stackTrace: stackTrace);
|
||||||
_session = _session?.copyWith(
|
_session = _session?.copyWith(
|
||||||
status: RemoteSessionStatus.error,
|
status: RemoteSessionStatus.error,
|
||||||
errorMessage: _localizedRemoteError(e, (details) => t.companionRemote.pairing.failedToConnect(error: details)),
|
errorMessage: _localizedRemoteError(
|
||||||
|
error,
|
||||||
|
(details) => t.companionRemote.pairing.failedToConnect(error: details),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
safeNotifyListeners();
|
safeNotifyListeners();
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _setupPeerServiceListeners() {
|
void _setupPeerServiceListeners(CompanionRemotePeerService peer, int generation) {
|
||||||
// A rebuild/reconnect can re-enter here with live subscriptions from the
|
// Only one peer owns the provider's listener set at a time. The identity
|
||||||
// previous peer service still attached; drop them first so events don't
|
// and generation checks also reject events already queued when teardown
|
||||||
// fan out to a stale service.
|
// synchronously cancels these subscriptions.
|
||||||
_cleanupSubscriptions();
|
_cleanupSubscriptions();
|
||||||
_commandSubscription = _peerService!.onCommandReceived.listen(
|
_commandSubscription = peer.onCommandReceived.listen(
|
||||||
(command) {
|
(command) {
|
||||||
|
if (!_ownsPeer(peer, generation)) return;
|
||||||
appLogger.d('CompanionRemote: Command received: ${command.type}');
|
appLogger.d('CompanionRemote: Command received: ${command.type}');
|
||||||
|
|
||||||
if (command.type == RemoteCommandType.deviceInfo) {
|
if (command.type == RemoteCommandType.deviceInfo) {
|
||||||
@@ -649,20 +819,24 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin
|
|||||||
onCommandReceived?.call(command);
|
onCommandReceived?.call(command);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (error) {
|
onError: (Object error) {
|
||||||
|
if (!_ownsPeer(peer, generation)) return;
|
||||||
appLogger.e('CompanionRemote: Stream error', error: error);
|
appLogger.e('CompanionRemote: Stream error', error: error);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
_deviceConnectedSubscription = _peerService!.onDeviceConnected.listen((device) {
|
_deviceConnectedSubscription = peer.onDeviceConnected.listen((device) {
|
||||||
|
if (!_ownsPeer(peer, generation)) return;
|
||||||
appLogger.d('CompanionRemote: Device connected: ${device.name}');
|
appLogger.d('CompanionRemote: Device connected: ${device.name}');
|
||||||
_session = _session?.copyWith(status: RemoteSessionStatus.connected, connectedDevice: device);
|
_session = _session?.copyWith(status: RemoteSessionStatus.connected, connectedDevice: device);
|
||||||
safeNotifyListeners();
|
safeNotifyListeners();
|
||||||
});
|
});
|
||||||
|
|
||||||
_deviceDisconnectedSubscription = _peerService!.onDeviceDisconnected.listen((_) {
|
_deviceDisconnectedSubscription = peer.onDeviceDisconnected.listen((_) {
|
||||||
appLogger.d('CompanionRemote: Device disconnected (intentional: $_intentionalDisconnect)');
|
if (!_ownsPeer(peer, generation)) return;
|
||||||
if (_intentionalDisconnect) {
|
final intentional = _isIntentionalDisconnect(peer, generation);
|
||||||
|
appLogger.d('CompanionRemote: Device disconnected (intentional: $intentional)');
|
||||||
|
if (intentional) {
|
||||||
_session = _session?.copyWith(status: RemoteSessionStatus.disconnected, connectedDevice: null);
|
_session = _session?.copyWith(status: RemoteSessionStatus.disconnected, connectedDevice: null);
|
||||||
safeNotifyListeners();
|
safeNotifyListeners();
|
||||||
} else if (isHost) {
|
} else if (isHost) {
|
||||||
@@ -676,17 +850,19 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin
|
|||||||
} else {
|
} else {
|
||||||
_session = _session?.copyWith(status: RemoteSessionStatus.reconnecting);
|
_session = _session?.copyWith(status: RemoteSessionStatus.reconnecting);
|
||||||
safeNotifyListeners();
|
safeNotifyListeners();
|
||||||
_scheduleReconnect();
|
_scheduleReconnect(generation);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
_errorSubscription = _peerService!.onError.listen((error) {
|
_errorSubscription = peer.onError.listen((error) {
|
||||||
|
if (!_ownsPeer(peer, generation)) return;
|
||||||
appLogger.e('CompanionRemote: Error: ${error.message}');
|
appLogger.e('CompanionRemote: Error: ${error.message}');
|
||||||
_session = _session?.copyWith(status: RemoteSessionStatus.error, errorMessage: error.message);
|
_session = _session?.copyWith(status: RemoteSessionStatus.error, errorMessage: error.message);
|
||||||
safeNotifyListeners();
|
safeNotifyListeners();
|
||||||
});
|
});
|
||||||
|
|
||||||
_statusSubscription = _peerService!.onConnectionStateChanged.listen((status) {
|
_statusSubscription = peer.onConnectionStateChanged.listen((status) {
|
||||||
|
if (!_ownsPeer(peer, generation)) return;
|
||||||
appLogger.d('CompanionRemote: Status changed: $status');
|
appLogger.d('CompanionRemote: Status changed: $status');
|
||||||
_session = _session?.copyWith(status: status);
|
_session = _session?.copyWith(status: status);
|
||||||
safeNotifyListeners();
|
safeNotifyListeners();
|
||||||
@@ -740,7 +916,8 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin
|
|||||||
_peerService!.sendCommand(RemoteCommand(type: type, data: data));
|
_peerService!.sendCommand(RemoteCommand(type: type, data: data));
|
||||||
}
|
}
|
||||||
|
|
||||||
void _scheduleReconnect() {
|
void _scheduleReconnect(int generation) {
|
||||||
|
if (generation != _remoteGeneration || isDisposed) return;
|
||||||
if (_reconnectAttempts >= _maxReconnectAttempts) {
|
if (_reconnectAttempts >= _maxReconnectAttempts) {
|
||||||
appLogger.w('CompanionRemote: Max reconnect attempts reached');
|
appLogger.w('CompanionRemote: Max reconnect attempts reached');
|
||||||
_session = _session?.copyWith(
|
_session = _session?.copyWith(
|
||||||
@@ -757,22 +934,35 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin
|
|||||||
appLogger.d('CompanionRemote: Reconnect attempt $_reconnectAttempts in ${delay.inSeconds}s');
|
appLogger.d('CompanionRemote: Reconnect attempt $_reconnectAttempts in ${delay.inSeconds}s');
|
||||||
|
|
||||||
_reconnectTimer?.cancel();
|
_reconnectTimer?.cancel();
|
||||||
_reconnectTimer = Timer(delay, _attemptReconnect);
|
_reconnectTimer = Timer(delay, () {
|
||||||
|
if (generation != _remoteGeneration || isDisposed) return;
|
||||||
|
unawaited(_attemptReconnect());
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _attemptReconnect() {
|
Future<void> _attemptReconnect() {
|
||||||
|
final generation = _remoteGeneration;
|
||||||
final active = _activeReconnect;
|
final active = _activeReconnect;
|
||||||
if (active != null) return active;
|
if (active != null && _activeReconnectGeneration == generation) {
|
||||||
|
return active;
|
||||||
|
}
|
||||||
|
|
||||||
late final Future<void> attempt;
|
late final Future<void> attempt;
|
||||||
attempt = _runReconnectAttempt().whenComplete(() {
|
attempt = _runReconnectAttempt(generation).whenComplete(() {
|
||||||
if (identical(_activeReconnect, attempt)) _activeReconnect = null;
|
if (identical(_activeReconnect, attempt)) {
|
||||||
|
_activeReconnect = null;
|
||||||
|
_activeReconnectGeneration = null;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
_activeReconnect = attempt;
|
_activeReconnect = attempt;
|
||||||
|
_activeReconnectGeneration = generation;
|
||||||
return attempt;
|
return attempt;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _runReconnectAttempt() async {
|
Future<void> _runReconnectAttempt(int generation) async {
|
||||||
if (_lastHostAddresses == null || !isCryptoReady) {
|
if (generation != _remoteGeneration || isDisposed) return;
|
||||||
|
final hostAddresses = _lastHostAddresses;
|
||||||
|
if (hostAddresses == null || !isCryptoReady) {
|
||||||
appLogger.w('CompanionRemote: No stored context for reconnect');
|
appLogger.w('CompanionRemote: No stored context for reconnect');
|
||||||
_session = _session?.copyWith(
|
_session = _session?.copyWith(
|
||||||
status: RemoteSessionStatus.error,
|
status: RemoteSessionStatus.error,
|
||||||
@@ -782,86 +972,133 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
appLogger.d('CompanionRemote: Attempting reconnect...');
|
||||||
appLogger.d('CompanionRemote: Attempting reconnect...');
|
final oldPeer = _peerService;
|
||||||
_cleanupSubscriptions();
|
_peerService = null;
|
||||||
try {
|
_cleanupSubscriptions();
|
||||||
await _peerService?.disconnect();
|
if (oldPeer != null) {
|
||||||
} finally {
|
await _disposePeerOnce(oldPeer);
|
||||||
_peerService = CompanionRemotePeerService();
|
}
|
||||||
_setupPeerServiceListeners();
|
if (generation != _remoteGeneration || isDisposed) return;
|
||||||
}
|
|
||||||
|
|
||||||
final authContextId = _authContextForId(_lastAuthContextId)?.id;
|
final candidate = _peerServiceFactory();
|
||||||
await _peerService!.joinSessionWithContexts(
|
_pendingRemotePeer = candidate;
|
||||||
|
_setupPeerServiceListeners(candidate, generation);
|
||||||
|
final authContextId = _authContextForId(_lastAuthContextId)?.id;
|
||||||
|
final expectedHostClientId = _lastHostClientId ?? '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await candidate.joinSessionWithContexts(
|
||||||
_deviceName,
|
_deviceName,
|
||||||
_platform,
|
_platform,
|
||||||
_lastHostAddresses!.first,
|
hostAddresses.first,
|
||||||
_authContexts,
|
_authContexts,
|
||||||
authContextId: authContextId,
|
authContextId: authContextId,
|
||||||
expectedHostClientId: _lastHostClientId ?? '',
|
expectedHostClientId: expectedHostClientId,
|
||||||
);
|
);
|
||||||
_lastAuthContextId = _peerService!.selectedAuthContextId ?? authContextId;
|
if (!_ownsPeer(candidate, generation)) {
|
||||||
_lastHostClientId = _peerService!.selectedHostClientId ?? _lastHostClientId;
|
await _disposePeerOnce(candidate);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_pendingRemotePeer = null;
|
||||||
|
_peerService = candidate;
|
||||||
|
_lastAuthContextId = candidate.selectedAuthContextId ?? authContextId;
|
||||||
|
_lastHostClientId = candidate.selectedHostClientId ?? _lastHostClientId;
|
||||||
_session = _session?.copyWith(status: RemoteSessionStatus.connected, errorMessage: null);
|
_session = _session?.copyWith(status: RemoteSessionStatus.connected, errorMessage: null);
|
||||||
_reconnectAttempts = 0;
|
_reconnectAttempts = 0;
|
||||||
safeNotifyListeners();
|
safeNotifyListeners();
|
||||||
appLogger.d('CompanionRemote: Reconnected successfully');
|
appLogger.d('CompanionRemote: Reconnected successfully');
|
||||||
} catch (e) {
|
} catch (error, stackTrace) {
|
||||||
appLogger.e('CompanionRemote: Reconnect failed', error: e);
|
if (!_ownsPeer(candidate, generation)) {
|
||||||
if (_session?.status == RemoteSessionStatus.reconnecting) {
|
await _disposePeerOnce(candidate);
|
||||||
_scheduleReconnect();
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_pendingRemotePeer = null;
|
||||||
|
_cleanupSubscriptions();
|
||||||
|
await _disposePeerOnce(candidate);
|
||||||
|
appLogger.e('CompanionRemote: Reconnect failed', error: error, stackTrace: stackTrace);
|
||||||
|
if (generation == _remoteGeneration && _session?.status == RemoteSessionStatus.reconnecting) {
|
||||||
|
_scheduleReconnect(generation);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void retryReconnectNow() {
|
Future<void> retryReconnectNow() {
|
||||||
_reconnectTimer?.cancel();
|
_reconnectTimer?.cancel();
|
||||||
|
_reconnectTimer = null;
|
||||||
_reconnectAttempts = 0;
|
_reconnectAttempts = 0;
|
||||||
_attemptReconnect();
|
return _attemptReconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
void cancelReconnect() {
|
Future<void> cancelReconnect() async {
|
||||||
_reconnectTimer?.cancel();
|
final wasHost = isHost || isHostServerRunning;
|
||||||
|
final detachedPeers = _invalidateRemoteLifecycle();
|
||||||
_reconnectAttempts = 0;
|
_reconnectAttempts = 0;
|
||||||
_session = _session?.copyWith(status: RemoteSessionStatus.disconnected, connectedDevice: null);
|
_session = null;
|
||||||
|
_isPlayerActive = false;
|
||||||
safeNotifyListeners();
|
safeNotifyListeners();
|
||||||
|
await _disposeDetachedPeers(detachedPeers);
|
||||||
|
if (wasHost) {
|
||||||
|
await _serializeLifecycle(_stopHostServerLocked);
|
||||||
|
} else {
|
||||||
|
stopDiscovery();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> leaveSession() async {
|
Future<void> leaveSession() async {
|
||||||
_intentionalDisconnect = true;
|
final leavingGeneration = _remoteGeneration;
|
||||||
_reconnectTimer?.cancel();
|
_markIntentionalDisconnect(_peerService, leavingGeneration);
|
||||||
|
_markIntentionalDisconnect(_pendingRemotePeer, leavingGeneration);
|
||||||
|
final wasHost = isHost || isHostServerRunning;
|
||||||
|
final detachedPeers = _invalidateRemoteLifecycle();
|
||||||
_reconnectAttempts = 0;
|
_reconnectAttempts = 0;
|
||||||
|
_session = null;
|
||||||
// Don't stop the host server when leaving — only stop discovery listening
|
|
||||||
if (_peerService != null && !isHost) {
|
|
||||||
appLogger.d('CompanionRemote: Leaving session');
|
|
||||||
await _peerService!.disconnect();
|
|
||||||
_peerService = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
_cleanupSubscriptions();
|
|
||||||
|
|
||||||
if (!isHost) {
|
|
||||||
_session = null;
|
|
||||||
}
|
|
||||||
_isPlayerActive = false;
|
_isPlayerActive = false;
|
||||||
_intentionalDisconnect = false;
|
|
||||||
safeNotifyListeners();
|
safeNotifyListeners();
|
||||||
|
|
||||||
|
await _disposeDetachedPeers(detachedPeers);
|
||||||
|
if (wasHost) {
|
||||||
|
await _serializeLifecycle(_stopHostServerLocked);
|
||||||
|
} else {
|
||||||
|
stopDiscovery();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_remoteGeneration++;
|
||||||
_reconnectTimer?.cancel();
|
_reconnectTimer?.cancel();
|
||||||
|
_reconnectTimer = null;
|
||||||
|
final current = _peerService;
|
||||||
|
final pending = _pendingRemotePeer;
|
||||||
|
_peerService = null;
|
||||||
|
_pendingRemotePeer = null;
|
||||||
|
_cleanupSubscriptions();
|
||||||
|
|
||||||
_boundActiveProfile?.removeListener(_scheduleAuthContextRefresh);
|
_boundActiveProfile?.removeListener(_scheduleAuthContextRefresh);
|
||||||
for (final sub in _profileServiceSubs) {
|
for (final sub in _profileServiceSubs) {
|
||||||
sub.cancel();
|
sub.cancel();
|
||||||
}
|
}
|
||||||
_profileServiceSubs.clear();
|
_profileServiceSubs.clear();
|
||||||
_discoveryService?.dispose();
|
_discoveryService?.dispose();
|
||||||
_peerService?.dispose();
|
unawaited(_disposeDetachedPeers((current: current, pending: pending)));
|
||||||
RemoteAuthService.instance.clearCache();
|
RemoteAuthService.instance.clearCache();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _RemoteConnectRequest {
|
||||||
|
const _RemoteConnectRequest({
|
||||||
|
required this.generation,
|
||||||
|
required this.wasHost,
|
||||||
|
required this.current,
|
||||||
|
required this.pending,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int generation;
|
||||||
|
final bool wasHost;
|
||||||
|
final CompanionRemotePeerService? current;
|
||||||
|
final CompanionRemotePeerService? pending;
|
||||||
|
}
|
||||||
|
|||||||
@@ -82,10 +82,17 @@ class _MobileRemoteScreenState extends State<MobileRemoteScreen> {
|
|||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: .center,
|
mainAxisAlignment: .center,
|
||||||
children: [
|
children: [
|
||||||
OutlinedButton(onPressed: () => provider.cancelReconnect(), child: Text(t.common.cancel)),
|
OutlinedButton(
|
||||||
|
onPressed: () async {
|
||||||
|
await provider.cancelReconnect();
|
||||||
|
},
|
||||||
|
child: Text(t.common.cancel),
|
||||||
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () => provider.retryReconnectNow(),
|
onPressed: () async {
|
||||||
|
await provider.retryReconnectNow();
|
||||||
|
},
|
||||||
child: Text(t.companionRemote.remote.retryNow),
|
child: Text(t.companionRemote.remote.retryNow),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -58,7 +58,9 @@ String constrainLogUploadPayload({required String header, required String logs,
|
|||||||
}
|
}
|
||||||
|
|
||||||
class LogsScreen extends StatefulWidget {
|
class LogsScreen extends StatefulWidget {
|
||||||
const LogsScreen({super.key});
|
const LogsScreen({super.key, this.httpClient});
|
||||||
|
|
||||||
|
final MediaServerHttpClient? httpClient;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<LogsScreen> createState() => _LogsScreenState();
|
State<LogsScreen> createState() => _LogsScreenState();
|
||||||
@@ -69,6 +71,8 @@ class _LogsScreenState extends State<LogsScreen> with MountedSetStateMixin {
|
|||||||
String _deviceInfo = '';
|
String _deviceInfo = '';
|
||||||
final ScrollController _scrollController = ScrollController();
|
final ScrollController _scrollController = ScrollController();
|
||||||
|
|
||||||
|
MediaServerHttpClient get _httpClient => widget.httpClient ?? httpClient;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -182,7 +186,7 @@ class _LogsScreenState extends State<LogsScreen> with MountedSetStateMixin {
|
|||||||
showLoadingDialog(context);
|
showLoadingDialog(context);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final response = await httpClient.post(
|
final response = await _httpClient.post(
|
||||||
'https://ice.plezy.app/logs',
|
'https://ice.plezy.app/logs',
|
||||||
body: logText,
|
body: logText,
|
||||||
headers: {'Content-Type': 'text/plain'},
|
headers: {'Content-Type': 'text/plain'},
|
||||||
@@ -199,20 +203,29 @@ class _LogsScreenState extends State<LogsScreen> with MountedSetStateMixin {
|
|||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
title: Text(t.messages.logsUploaded),
|
title: Text(t.messages.logsUploaded),
|
||||||
content: Row(
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text('${t.messages.logId}: '),
|
Text('${t.messages.logId}:'),
|
||||||
SelectableText(
|
const SizedBox(height: 8),
|
||||||
id,
|
Row(
|
||||||
style: const TextStyle(fontWeight: .bold, fontFamily: 'monospace', fontSize: 18),
|
children: [
|
||||||
),
|
Expanded(
|
||||||
const SizedBox(width: 8),
|
child: SelectableText(
|
||||||
IconButton(
|
id,
|
||||||
icon: const AppIcon(Symbols.content_copy_rounded, size: 20),
|
style: const TextStyle(fontWeight: .bold, fontFamily: 'monospace', fontSize: 18),
|
||||||
onPressed: () {
|
),
|
||||||
Clipboard.setData(ClipboardData(text: id));
|
),
|
||||||
showSuccessSnackBar(context, t.messages.logsCopied);
|
const SizedBox(width: 8),
|
||||||
},
|
IconButton(
|
||||||
|
icon: const AppIcon(Symbols.content_copy_rounded, size: 20),
|
||||||
|
onPressed: () {
|
||||||
|
Clipboard.setData(ClipboardData(text: id));
|
||||||
|
showSuccessSnackBar(context, t.messages.logsCopied);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import '../../widgets/settings_builder.dart';
|
|||||||
import '../../widgets/settings_section.dart';
|
import '../../widgets/settings_section.dart';
|
||||||
import '../../profiles/active_profile_provider.dart';
|
import '../../profiles/active_profile_provider.dart';
|
||||||
import '../../profiles/profile.dart';
|
import '../../profiles/profile.dart';
|
||||||
|
import '../../watch_together/services/watch_together_relay_endpoint.dart';
|
||||||
import 'about_screen.dart';
|
import 'about_screen.dart';
|
||||||
import 'add_connection_screen.dart';
|
import 'add_connection_screen.dart';
|
||||||
import 'appearance_settings_screen.dart';
|
import 'appearance_settings_screen.dart';
|
||||||
@@ -801,6 +802,7 @@ class _RelayUrlDialog extends StatefulWidget {
|
|||||||
class _RelayUrlDialogState extends State<_RelayUrlDialog> {
|
class _RelayUrlDialogState extends State<_RelayUrlDialog> {
|
||||||
late final TextEditingController _controller;
|
late final TextEditingController _controller;
|
||||||
final _saveFocusNode = FocusNode(debugLabel: 'WatchTogetherRelaySave');
|
final _saveFocusNode = FocusNode(debugLabel: 'WatchTogetherRelaySave');
|
||||||
|
bool _relayUrlInvalid = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -824,8 +826,19 @@ class _RelayUrlDialogState extends State<_RelayUrlDialog> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _save() async {
|
Future<void> _save() async {
|
||||||
final trimmed = _controller.text.trim();
|
final value = _controller.text;
|
||||||
await widget.settingsService.write(settings.SettingsService.customRelayUrl, trimmed.isEmpty ? null : trimmed);
|
if (value.trim().isEmpty) {
|
||||||
|
await widget.settingsService.write(settings.SettingsService.customRelayUrl, null);
|
||||||
|
if (mounted) Navigator.pop(context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final endpoint = WatchTogetherRelayEndpoint.tryParseCustom(value);
|
||||||
|
if (endpoint == null) {
|
||||||
|
setState(() => _relayUrlInvalid = true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await widget.settingsService.write(settings.SettingsService.customRelayUrl, endpoint.canonicalBaseUrl);
|
||||||
if (mounted) Navigator.pop(context);
|
if (mounted) Navigator.pop(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -835,9 +848,18 @@ class _RelayUrlDialogState extends State<_RelayUrlDialog> {
|
|||||||
title: Text(t.settings.watchTogetherRelay),
|
title: Text(t.settings.watchTogetherRelay),
|
||||||
content: FocusableTextField(
|
content: FocusableTextField(
|
||||||
controller: _controller,
|
controller: _controller,
|
||||||
decoration: InputDecoration(labelText: 'URL', hintText: t.settings.watchTogetherRelayHint),
|
decoration: InputDecoration(
|
||||||
|
labelText: 'URL',
|
||||||
|
hintText: t.settings.watchTogetherRelayHint,
|
||||||
|
errorText: _relayUrlInvalid ? t.settings.watchTogetherRelayInvalid : null,
|
||||||
|
),
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
textInputAction: TextInputAction.done,
|
textInputAction: TextInputAction.done,
|
||||||
|
onChanged: (_) {
|
||||||
|
if (_relayUrlInvalid) {
|
||||||
|
setState(() => _relayUrlInvalid = false);
|
||||||
|
}
|
||||||
|
},
|
||||||
onEditingComplete: () => _saveFocusNode.requestFocus(),
|
onEditingComplete: () => _saveFocusNode.requestFocus(),
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,8 @@ import '../models/transcode_quality_preset.dart';
|
|||||||
import '../navigation/navigation_tabs.dart';
|
import '../navigation/navigation_tabs.dart';
|
||||||
import '../utils/platform_detector.dart';
|
import '../utils/platform_detector.dart';
|
||||||
import 'trackers/tracker_constants.dart';
|
import 'trackers/tracker_constants.dart';
|
||||||
|
import '../profiles/profile.dart';
|
||||||
|
import '../watch_together/services/watch_together_relay_endpoint.dart';
|
||||||
|
|
||||||
enum ThemeMode { system, light, dark, oled }
|
enum ThemeMode { system, light, dark, oled }
|
||||||
|
|
||||||
@@ -216,6 +218,15 @@ String? _trimEmptyAsNull(String? v) {
|
|||||||
return (t == null || t.isEmpty) ? null : t;
|
return (t == null || t.isEmpty) ? null : t;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String? _normalizeRelayBaseUrl(String? value) {
|
||||||
|
if (value == null || value.trim().isEmpty) return null;
|
||||||
|
final endpoint = WatchTogetherRelayEndpoint.tryParseCustom(value);
|
||||||
|
if (endpoint == null) {
|
||||||
|
throw FormatException('Invalid Watch Together relay base URL');
|
||||||
|
}
|
||||||
|
return endpoint.canonicalBaseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
String _legacyMpvEntriesToText(List<dynamic> entries) {
|
String _legacyMpvEntriesToText(List<dynamic> entries) {
|
||||||
final lines = <String>[];
|
final lines = <String>[];
|
||||||
for (final item in entries) {
|
for (final item in entries) {
|
||||||
@@ -431,8 +442,15 @@ class SettingsService extends BaseSharedPreferencesService {
|
|||||||
static const appLocale = _AppLocalePref();
|
static const appLocale = _AppLocalePref();
|
||||||
static const autoPip = _AutoPipPref();
|
static const autoPip = _AutoPipPref();
|
||||||
static const customDownloadPath = NullableStringPref('custom_download_path');
|
static const customDownloadPath = NullableStringPref('custom_download_path');
|
||||||
static final customRelayUrl = NullableStringPref('custom_relay_url', transform: _trimEmptyAsNull);
|
static final customRelayUrl = NullableStringPref('custom_relay_url', transform: _normalizeRelayBaseUrl);
|
||||||
static const recentRooms = NullableStringPref('watch_together_recent_rooms');
|
|
||||||
|
static NullableStringPref recentRoomsForProfile(String profileId) {
|
||||||
|
if (profileId.trim().isEmpty) {
|
||||||
|
throw ArgumentError.value(profileId, 'profileId', 'Must not be empty');
|
||||||
|
}
|
||||||
|
return NullableStringPref(profileScopedPrefsKey(profileId, 'watch_together_recent_rooms'));
|
||||||
|
}
|
||||||
|
|
||||||
static final companionRemoteLastHostAddress = NullableStringPref(
|
static final companionRemoteLastHostAddress = NullableStringPref(
|
||||||
'companion_remote_last_host_address',
|
'companion_remote_last_host_address',
|
||||||
transform: _trimEmptyAsNull,
|
transform: _trimEmptyAsNull,
|
||||||
@@ -576,6 +594,21 @@ class SettingsService extends BaseSharedPreferencesService {
|
|||||||
_cachedInstance = null;
|
_cachedInstance = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> onInit() async {
|
||||||
|
const legacyRecentRoomsKey = 'watch_together_recent_rooms';
|
||||||
|
await prefs.remove(legacyRecentRoomsKey);
|
||||||
|
|
||||||
|
final storedRelay = prefs.getString(customRelayUrl.key);
|
||||||
|
if (storedRelay == null) return;
|
||||||
|
final endpoint = WatchTogetherRelayEndpoint.tryParseCustom(storedRelay);
|
||||||
|
if (endpoint == null) {
|
||||||
|
await prefs.remove(customRelayUrl.key);
|
||||||
|
} else if (endpoint.canonicalBaseUrl != storedRelay) {
|
||||||
|
await prefs.setString(customRelayUrl.key, endpoint.canonicalBaseUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolves a video mute toggle without replacing the saved volume with 0.
|
/// Resolves a video mute toggle without replacing the saved volume with 0.
|
||||||
///
|
///
|
||||||
/// `persistedVolume` is the non-zero value callers should keep in [volume],
|
/// `persistedVolume` is the non-zero value callers should keep in [volume],
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import '../../utils/app_logger.dart';
|
|||||||
import '../../utils/platform_http_client_stub.dart'
|
import '../../utils/platform_http_client_stub.dart'
|
||||||
if (dart.library.io) '../../utils/platform_http_client_io.dart'
|
if (dart.library.io) '../../utils/platform_http_client_io.dart'
|
||||||
as platform;
|
as platform;
|
||||||
import '../../watch_together/services/watch_together_peer_service.dart';
|
import '../../watch_together/services/watch_together_relay_endpoint.dart';
|
||||||
import 'tracker_constants.dart';
|
import 'tracker_constants.dart';
|
||||||
|
|
||||||
/// Client for the Plezy relay's `/auth/*` OAuth proxy.
|
/// Client for the Plezy relay's `/auth/*` OAuth proxy.
|
||||||
@@ -19,7 +19,7 @@ import 'tracker_constants.dart';
|
|||||||
/// scheme is required — works identically on TVs without a browser.
|
/// scheme is required — works identically on TVs without a browser.
|
||||||
class OAuthProxyClient {
|
class OAuthProxyClient {
|
||||||
/// Public base URL of the Plezy relay; colocated with Watch Together.
|
/// Public base URL of the Plezy relay; colocated with Watch Together.
|
||||||
static String get baseUrl => WatchTogetherPeerService.defaultBaseUrl;
|
static String get baseUrl => WatchTogetherRelayEndpoint.defaultEndpoint.canonicalBaseUrl;
|
||||||
|
|
||||||
final http.Client _http;
|
final http.Client _http;
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import 'dart:convert';
|
|||||||
|
|
||||||
import 'playback_state.dart';
|
import 'playback_state.dart';
|
||||||
|
|
||||||
/// Types of sync messages sent over the relay data channel (protocol v2).
|
/// Types of sync messages sent over the relay data channel (protocol v3).
|
||||||
enum SyncMessageType {
|
enum SyncMessageType {
|
||||||
/// Authoritative playback state broadcast by the host
|
/// Authoritative playback state broadcast by the host
|
||||||
state,
|
state,
|
||||||
@@ -37,7 +37,7 @@ class SyncMessage {
|
|||||||
/// Current sync protocol version, carried on join messages. Peers with a
|
/// Current sync protocol version, carried on join messages. Peers with a
|
||||||
/// different version are excluded from readiness gating and surfaced as
|
/// different version are excluded from readiness gating and surfaced as
|
||||||
/// needing an update.
|
/// needing an update.
|
||||||
static const int protocolVersion = 2;
|
static const int protocolVersion = 3;
|
||||||
|
|
||||||
/// Type of this message
|
/// Type of this message
|
||||||
final SyncMessageType type;
|
final SyncMessageType type;
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
int watchTogetherSystemNowMs() => DateTime.now().millisecondsSinceEpoch;
|
int watchTogetherSystemNowMs() => DateTime.now().millisecondsSinceEpoch;
|
||||||
|
|
||||||
String watchTogetherHostPeerId(String sessionId) => 'wt-${sessionId.toUpperCase()}';
|
|
||||||
|
|
||||||
bool orderedStringListsEqual(List<String> first, List<String> second) {
|
bool orderedStringListsEqual(List<String> first, List<String> second) {
|
||||||
if (first.length != second.length) return false;
|
if (first.length != second.length) return false;
|
||||||
for (var i = 0; i < first.length; i++) {
|
for (var i = 0; i < first.length; i++) {
|
||||||
|
|||||||
@@ -5,21 +5,26 @@ import 'dart:math';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
import '../../mpv/mpv.dart';
|
import '../../mpv/mpv.dart';
|
||||||
import '../../services/settings_service.dart';
|
|
||||||
import '../../utils/app_logger.dart';
|
import '../../utils/app_logger.dart';
|
||||||
import '../models/playback_state.dart';
|
import '../models/playback_state.dart';
|
||||||
import '../models/sync_message.dart';
|
import '../models/sync_message.dart';
|
||||||
import '../models/watch_session.dart';
|
import '../models/watch_session.dart';
|
||||||
import '../primitives.dart';
|
|
||||||
import '../services/current_playback_dispatcher.dart';
|
import '../services/current_playback_dispatcher.dart';
|
||||||
|
import '../services/relay_protocol.g.dart';
|
||||||
import '../services/watch_together_controller.dart';
|
import '../services/watch_together_controller.dart';
|
||||||
import '../services/watch_together_peer_service.dart';
|
import '../services/watch_together_peer_service.dart';
|
||||||
|
import '../services/watch_together_relay_endpoint.dart';
|
||||||
|
|
||||||
/// Callback type for when media switches (for guest navigation). Returns
|
/// Callback type for when media switches (for guest navigation). Returns
|
||||||
/// whether the switch was handled; unhandled keys are re-dispatched on the
|
/// whether the switch was handled; unhandled keys are re-dispatched on the
|
||||||
/// host's next state heartbeat.
|
/// host's next state heartbeat.
|
||||||
typedef MediaSwitchCallback = Future<bool> Function(String ratingKey, ServerId serverId, String mediaTitle);
|
typedef MediaSwitchCallback = Future<bool> Function(String ratingKey, ServerId serverId, String mediaTitle);
|
||||||
|
|
||||||
|
typedef WatchTogetherPeerServiceFactory = WatchTogetherPeerService Function({WatchTogetherRelayEndpoint? endpoint});
|
||||||
|
|
||||||
|
WatchTogetherPeerService _createWatchTogetherPeerService({WatchTogetherRelayEndpoint? endpoint}) =>
|
||||||
|
WatchTogetherPeerService(endpoint: endpoint);
|
||||||
|
|
||||||
/// Provider for Watch Together functionality
|
/// Provider for Watch Together functionality
|
||||||
///
|
///
|
||||||
/// This provider manages:
|
/// This provider manages:
|
||||||
@@ -29,9 +34,15 @@ typedef MediaSwitchCallback = Future<bool> Function(String ratingKey, ServerId s
|
|||||||
/// - Participant list
|
/// - Participant list
|
||||||
/// - Media switching across the session
|
/// - Media switching across the session
|
||||||
class WatchTogetherProvider with ChangeNotifier {
|
class WatchTogetherProvider with ChangeNotifier {
|
||||||
|
WatchTogetherProvider({WatchTogetherPeerServiceFactory peerServiceFactory = _createWatchTogetherPeerService})
|
||||||
|
: _peerServiceFactory = peerServiceFactory;
|
||||||
|
|
||||||
|
final WatchTogetherPeerServiceFactory _peerServiceFactory;
|
||||||
|
|
||||||
WatchSession? _session;
|
WatchSession? _session;
|
||||||
WatchTogetherPeerService? _peerService;
|
WatchTogetherPeerService? _peerService;
|
||||||
WatchTogetherController? _controller;
|
WatchTogetherController? _controller;
|
||||||
|
PeerError? _recoverableTransportError;
|
||||||
final List<Participant> _participants = [];
|
final List<Participant> _participants = [];
|
||||||
bool _isSyncing = false;
|
bool _isSyncing = false;
|
||||||
bool _isWaitingForPeers = false;
|
bool _isWaitingForPeers = false;
|
||||||
@@ -88,6 +99,7 @@ class WatchTogetherProvider with ChangeNotifier {
|
|||||||
StreamSubscription<String>? _peerDisconnectedSubscription;
|
StreamSubscription<String>? _peerDisconnectedSubscription;
|
||||||
StreamSubscription<SyncMessage>? _messageSubscription;
|
StreamSubscription<SyncMessage>? _messageSubscription;
|
||||||
StreamSubscription<PeerError>? _errorSubscription;
|
StreamSubscription<PeerError>? _errorSubscription;
|
||||||
|
StreamSubscription<void>? _sessionEndedSubscription;
|
||||||
|
|
||||||
// Getters
|
// Getters
|
||||||
bool get isInSession => _session != null && _session!.state != SessionState.disconnected;
|
bool get isInSession => _session != null && _session!.state != SessionState.disconnected;
|
||||||
@@ -210,9 +222,36 @@ class WatchTogetherProvider with ChangeNotifier {
|
|||||||
_controller?.requestState();
|
_controller?.requestState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _requestCurrentPlaybackSnapshotIfHostConnected(WatchTogetherPeerService peerService) {
|
||||||
|
if (!identical(_peerService, peerService)) return;
|
||||||
|
final session = _session;
|
||||||
|
final hostPeerId = session?.hostPeerId;
|
||||||
|
if (session == null || session.isHost || hostPeerId == null || !peerService.connectedPeers.contains(hostPeerId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_controller?.updateSession(session);
|
||||||
|
requestCurrentPlaybackSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
/// Wire up reconnection handler to re-announce join and re-sync state
|
/// Wire up reconnection handler to re-announce join and re-sync state
|
||||||
void _wireReconnectHandler() {
|
void _wireReconnectHandler() {
|
||||||
_peerService!.onReconnected = () {
|
final peerService = _peerService!;
|
||||||
|
peerService.onReconnected = () {
|
||||||
|
if (_disposed || !identical(_peerService, peerService)) return;
|
||||||
|
|
||||||
|
final recoverableError = _recoverableTransportError;
|
||||||
|
final session = _session;
|
||||||
|
if (recoverableError != null &&
|
||||||
|
session != null &&
|
||||||
|
session.state == SessionState.error &&
|
||||||
|
session.errorMessage == recoverableError.message) {
|
||||||
|
_session = session.copyWith(state: SessionState.connected, errorMessage: null);
|
||||||
|
_recoverableTransportError = null;
|
||||||
|
notifyListeners();
|
||||||
|
} else {
|
||||||
|
_recoverableTransportError = null;
|
||||||
|
}
|
||||||
|
|
||||||
_controller?.announceJoin(_displayName);
|
_controller?.announceJoin(_displayName);
|
||||||
_controller?.onReconnected();
|
_controller?.onReconnected();
|
||||||
};
|
};
|
||||||
@@ -302,6 +341,7 @@ class WatchTogetherProvider with ChangeNotifier {
|
|||||||
/// Create a new watch together session as host
|
/// Create a new watch together session as host
|
||||||
Future<String> createSession({
|
Future<String> createSession({
|
||||||
required ControlMode controlMode,
|
required ControlMode controlMode,
|
||||||
|
required WatchTogetherRelayEndpoint relayEndpoint,
|
||||||
String? displayName,
|
String? displayName,
|
||||||
String? sessionId,
|
String? sessionId,
|
||||||
String? mediaRatingKey,
|
String? mediaRatingKey,
|
||||||
@@ -314,8 +354,7 @@ class WatchTogetherProvider with ChangeNotifier {
|
|||||||
|
|
||||||
appLogger.d('WatchTogether: Creating session with control mode: $controlMode');
|
appLogger.d('WatchTogether: Creating session with control mode: $controlMode');
|
||||||
|
|
||||||
final customRelayUrl = SettingsService.instanceOrNull?.read(SettingsService.customRelayUrl);
|
_peerService = _peerServiceFactory(endpoint: relayEndpoint);
|
||||||
_peerService = WatchTogetherPeerService(customBaseUrl: customRelayUrl);
|
|
||||||
_setupPeerServiceListeners();
|
_setupPeerServiceListeners();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -323,7 +362,7 @@ class WatchTogetherProvider with ChangeNotifier {
|
|||||||
|
|
||||||
_session = WatchSession.createAsHost(
|
_session = WatchSession.createAsHost(
|
||||||
sessionId: createdSessionId,
|
sessionId: createdSessionId,
|
||||||
hostPeerId: _peerService!.myPeerId!,
|
hostPeerId: _peerService!.hostPeerId!,
|
||||||
controlMode: controlMode,
|
controlMode: controlMode,
|
||||||
mediaRatingKey: mediaRatingKey,
|
mediaRatingKey: mediaRatingKey,
|
||||||
mediaServerId: mediaServerId,
|
mediaServerId: mediaServerId,
|
||||||
@@ -351,113 +390,139 @@ class WatchTogetherProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Join an existing session as guest
|
/// Join an existing session as guest
|
||||||
Future<void> joinSession(String sessionId, {String? displayName}) async {
|
Future<void> joinSession(
|
||||||
|
String sessionId, {
|
||||||
|
required WatchTogetherRelayEndpoint relayEndpoint,
|
||||||
|
String? displayName,
|
||||||
|
}) async {
|
||||||
// Clean up any existing session
|
// Clean up any existing session
|
||||||
await leaveSession();
|
await leaveSession();
|
||||||
_playbackDispatcher.reset();
|
_playbackDispatcher.reset();
|
||||||
|
|
||||||
appLogger.d('WatchTogether: Joining session: $sessionId');
|
appLogger.d('WatchTogether: Joining session: $sessionId');
|
||||||
|
|
||||||
final customRelayUrl = SettingsService.instanceOrNull?.read(SettingsService.customRelayUrl);
|
final peerService = _peerServiceFactory(endpoint: relayEndpoint);
|
||||||
_peerService = WatchTogetherPeerService(customBaseUrl: customRelayUrl);
|
_peerService = peerService;
|
||||||
_setupPeerServiceListeners();
|
_setupPeerServiceListeners();
|
||||||
|
|
||||||
_session = WatchSession.joinAsGuest(sessionId: sessionId);
|
final joiningSession = WatchSession.joinAsGuest(sessionId: sessionId);
|
||||||
|
_session = joiningSession;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await _peerService!.joinSession(sessionId);
|
await peerService.joinSession(sessionId);
|
||||||
|
if (!identical(_peerService, peerService)) {
|
||||||
|
throw StateError('Watch Together join became stale');
|
||||||
|
}
|
||||||
|
|
||||||
// Session will be fully configured when we receive sessionConfig from host
|
// Host authority comes from the relay setup response rather than the
|
||||||
_session = _session!.copyWith(state: SessionState.connected, hostPeerId: watchTogetherHostPeerId(sessionId));
|
// public room code or a client-derived routing label.
|
||||||
|
_session = joiningSession.copyWith(state: SessionState.connected, hostPeerId: peerService.hostPeerId);
|
||||||
|
|
||||||
_displayName = displayName ?? _generateDisplayName();
|
_displayName = displayName ?? _generateDisplayName();
|
||||||
|
|
||||||
_controller = WatchTogetherController(peerService: _peerService!, session: _session!);
|
_controller = WatchTogetherController(peerService: peerService, session: _session!);
|
||||||
|
|
||||||
_wireController();
|
_wireController();
|
||||||
_wireReconnectHandler();
|
_wireReconnectHandler();
|
||||||
|
|
||||||
// Add self to participants
|
// Add self to participants
|
||||||
_participants.add(Participant(peerId: _peerService!.myPeerId!, displayName: _displayName, isHost: false));
|
_participants.add(Participant(peerId: peerService.myPeerId!, displayName: _displayName, isHost: false));
|
||||||
|
|
||||||
// Announce join to other participants
|
// Announce join to other participants
|
||||||
_controller!.announceJoin(_displayName);
|
_controller!.announceJoin(_displayName);
|
||||||
requestCurrentPlaybackSnapshot();
|
_requestCurrentPlaybackSnapshotIfHostConnected(peerService);
|
||||||
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
appLogger.d('WatchTogether: Joined session successfully');
|
appLogger.d('WatchTogether: Joined session successfully');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
appLogger.e('WatchTogether: Failed to join session', error: e);
|
appLogger.e('WatchTogether: Failed to join session', error: e);
|
||||||
await leaveSession();
|
if (identical(_peerService, peerService)) {
|
||||||
|
await leaveSession();
|
||||||
|
}
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Enter a room by code — joins if it exists, creates if empty.
|
/// Enter a room by code — joins any reserved room and creates only when the
|
||||||
|
/// relay reports that no room reservation exists.
|
||||||
///
|
///
|
||||||
/// Returns `true` if the user became the host.
|
/// Returns `true` if the user became the host.
|
||||||
Future<bool> enterRoom(String sessionId, {ControlMode controlMode = ControlMode.anyone, String? displayName}) async {
|
Future<bool> enterRoom(
|
||||||
// Probe the relay with a lightweight peer service to check room occupancy,
|
String sessionId, {
|
||||||
// then do a single createSession or joinSession. This avoids the crash-prone
|
required WatchTogetherRelayEndpoint relayEndpoint,
|
||||||
// join→teardown→create cycle on the provider.
|
ControlMode controlMode = ControlMode.anyone,
|
||||||
final customRelayUrl = SettingsService.instanceOrNull?.read(SettingsService.customRelayUrl);
|
String? displayName,
|
||||||
final probe = WatchTogetherPeerService(customBaseUrl: customRelayUrl);
|
}) async {
|
||||||
bool shouldBeHost;
|
// A successful join reserves a durable guest identity. Release that probe
|
||||||
|
// identity before opening the provider's real connection.
|
||||||
|
final probe = _peerServiceFactory(endpoint: relayEndpoint);
|
||||||
|
var shouldBeHost = false;
|
||||||
try {
|
try {
|
||||||
await probe.joinSession(sessionId);
|
try {
|
||||||
shouldBeHost = probe.connectedPeers.isEmpty;
|
await probe.joinSession(sessionId);
|
||||||
} on PeerError catch (e) {
|
} on PeerError catch (error) {
|
||||||
if (e.serverCode == 'room_not_found') {
|
if (error.serverCode != RelayProtocol.roomNotFoundCode) rethrow;
|
||||||
shouldBeHost = true;
|
shouldBeHost = true;
|
||||||
} else {
|
|
||||||
await probe.disconnect();
|
|
||||||
probe.dispose();
|
|
||||||
rethrow;
|
|
||||||
}
|
}
|
||||||
|
if (!shouldBeHost) {
|
||||||
|
await probe.releaseSession();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await probe.disconnect();
|
||||||
|
probe.dispose();
|
||||||
}
|
}
|
||||||
await probe.disconnect();
|
|
||||||
probe.dispose();
|
|
||||||
|
|
||||||
if (shouldBeHost) {
|
if (shouldBeHost) {
|
||||||
await createSession(controlMode: controlMode, displayName: displayName, sessionId: sessionId);
|
await createSession(
|
||||||
|
controlMode: controlMode,
|
||||||
|
relayEndpoint: relayEndpoint,
|
||||||
|
displayName: displayName,
|
||||||
|
sessionId: sessionId,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
await joinSession(sessionId, displayName: displayName);
|
await joinSession(sessionId, relayEndpoint: relayEndpoint, displayName: displayName);
|
||||||
}
|
}
|
||||||
return shouldBeHost;
|
return shouldBeHost;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Leave the current session
|
/// Leave the current session. Local callbacks and player bindings are
|
||||||
|
/// detached synchronously; relay release remains awaitable and observable.
|
||||||
Future<void> leaveSession() async {
|
Future<void> leaveSession() async {
|
||||||
if (_session == null) return;
|
if (_session == null && _peerService == null) return;
|
||||||
|
|
||||||
appLogger.d('WatchTogether: Leaving session');
|
appLogger.d('WatchTogether: Leaving session');
|
||||||
|
final peerService = _detachLocalSession(announceLeave: true);
|
||||||
|
if (peerService != null) {
|
||||||
|
await _finishPeerTeardown(peerService, release: true);
|
||||||
|
}
|
||||||
|
appLogger.d('WatchTogether: Session left');
|
||||||
|
}
|
||||||
|
|
||||||
// Announce leave if connected
|
WatchTogetherPeerService? _detachLocalSession({required bool announceLeave}) {
|
||||||
_controller?.announceLeave();
|
_recoverableTransportError = null;
|
||||||
|
if (announceLeave) _controller?.announceLeave();
|
||||||
|
|
||||||
// Clean up subscriptions
|
final peerService = _peerService;
|
||||||
unawaited(_peerConnectedSubscription?.cancel());
|
if (peerService != null) peerService.onReconnected = null;
|
||||||
unawaited(_peerDisconnectedSubscription?.cancel());
|
|
||||||
unawaited(_messageSubscription?.cancel());
|
|
||||||
unawaited(_errorSubscription?.cancel());
|
|
||||||
|
|
||||||
|
_observeSubscriptionCancellation(_peerConnectedSubscription?.cancel());
|
||||||
|
_observeSubscriptionCancellation(_peerDisconnectedSubscription?.cancel());
|
||||||
|
_observeSubscriptionCancellation(_messageSubscription?.cancel());
|
||||||
|
_observeSubscriptionCancellation(_errorSubscription?.cancel());
|
||||||
|
_observeSubscriptionCancellation(_sessionEndedSubscription?.cancel());
|
||||||
_peerConnectedSubscription = null;
|
_peerConnectedSubscription = null;
|
||||||
_peerDisconnectedSubscription = null;
|
_peerDisconnectedSubscription = null;
|
||||||
_messageSubscription = null;
|
_messageSubscription = null;
|
||||||
_errorSubscription = null;
|
_errorSubscription = null;
|
||||||
|
_sessionEndedSubscription = null;
|
||||||
|
|
||||||
// Cancel host reconnect grace period
|
_hostReconnectTimer?.cancel();
|
||||||
_cancelHostReconnectGracePeriod();
|
_hostReconnectTimer = null;
|
||||||
|
_isWaitingForHostReconnect = false;
|
||||||
|
|
||||||
// Clean up services
|
|
||||||
_controller?.dispose();
|
_controller?.dispose();
|
||||||
_controller = null;
|
_controller = null;
|
||||||
|
|
||||||
await _peerService?.disconnect();
|
|
||||||
_peerService?.dispose();
|
|
||||||
_peerService = null;
|
_peerService = null;
|
||||||
|
|
||||||
_session = null;
|
_session = null;
|
||||||
_participants.clear();
|
_participants.clear();
|
||||||
_isSyncing = false;
|
_isSyncing = false;
|
||||||
@@ -468,8 +533,51 @@ class WatchTogetherProvider with ChangeNotifier {
|
|||||||
_lastActionEventMs.clear();
|
_lastActionEventMs.clear();
|
||||||
_hostIntentionallyLeft = false;
|
_hostIntentionallyLeft = false;
|
||||||
|
|
||||||
notifyListeners();
|
if (!_disposed) notifyListeners();
|
||||||
appLogger.d('WatchTogether: Session left');
|
return peerService;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _observeSubscriptionCancellation(Future<void>? cancellation) {
|
||||||
|
if (cancellation == null) return;
|
||||||
|
unawaited(
|
||||||
|
cancellation.catchError((Object error, StackTrace stackTrace) {
|
||||||
|
appLogger.e('WatchTogether: Failed to cancel local subscription', error: error, stackTrace: stackTrace);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _finishPeerTeardown(WatchTogetherPeerService peerService, {required bool release}) async {
|
||||||
|
Object? failure;
|
||||||
|
StackTrace? failureStackTrace;
|
||||||
|
if (release) {
|
||||||
|
try {
|
||||||
|
await peerService.releaseSession();
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
failure = error;
|
||||||
|
failureStackTrace = stackTrace;
|
||||||
|
appLogger.e('WatchTogether: Failed to release relay ownership', error: error, stackTrace: stackTrace);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await peerService.disconnect();
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
failure ??= error;
|
||||||
|
failureStackTrace ??= stackTrace;
|
||||||
|
appLogger.e('WatchTogether: Failed to disconnect relay transport', error: error, stackTrace: stackTrace);
|
||||||
|
} finally {
|
||||||
|
peerService.dispose();
|
||||||
|
}
|
||||||
|
if (failure != null) {
|
||||||
|
Error.throwWithStackTrace(failure, failureStackTrace!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _leaveSessionBestEffort(String reason) {
|
||||||
|
unawaited(
|
||||||
|
leaveSession().catchError((Object error, StackTrace stackTrace) {
|
||||||
|
appLogger.e('WatchTogether: Best-effort $reason failed', error: error, stackTrace: stackTrace);
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Attach a player to the sync controller for the given media.
|
/// Attach a player to the sync controller for the given media.
|
||||||
@@ -517,7 +625,9 @@ class WatchTogetherProvider with ChangeNotifier {
|
|||||||
|
|
||||||
/// Set up listeners for peer service events
|
/// Set up listeners for peer service events
|
||||||
void _setupPeerServiceListeners() {
|
void _setupPeerServiceListeners() {
|
||||||
_peerConnectedSubscription = _peerService!.onPeerConnected.listen((peerId) {
|
final peerService = _peerService!;
|
||||||
|
_peerConnectedSubscription = peerService.onPeerConnected.listen((peerId) {
|
||||||
|
if (_disposed || !identical(_peerService, peerService)) return;
|
||||||
appLogger.d('WatchTogether: Peer connected: $peerId');
|
appLogger.d('WatchTogether: Peer connected: $peerId');
|
||||||
|
|
||||||
// If host reconnected during grace period, cancel the timer
|
// If host reconnected during grace period, cancel the timer
|
||||||
@@ -526,14 +636,15 @@ class WatchTogetherProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!isHost && peerId == _session?.hostPeerId) {
|
if (!isHost && peerId == _session?.hostPeerId) {
|
||||||
requestCurrentPlaybackSnapshot();
|
_requestCurrentPlaybackSnapshotIfHostConnected(peerService);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Peer will announce themselves with a join message
|
// Peer will announce themselves with a join message
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
});
|
});
|
||||||
|
|
||||||
_peerDisconnectedSubscription = _peerService!.onPeerDisconnected.listen((peerId) {
|
_peerDisconnectedSubscription = peerService.onPeerDisconnected.listen((peerId) {
|
||||||
|
if (_disposed || !identical(_peerService, peerService)) return;
|
||||||
appLogger.d('WatchTogether: Peer disconnected: $peerId');
|
appLogger.d('WatchTogether: Peer disconnected: $peerId');
|
||||||
|
|
||||||
// Capture display name before removal for notification
|
// Capture display name before removal for notification
|
||||||
@@ -555,19 +666,45 @@ class WatchTogetherProvider with ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
});
|
});
|
||||||
|
|
||||||
_messageSubscription = _peerService!.onMessageReceived.listen((message) {
|
_messageSubscription = peerService.onMessageReceived.listen((message) {
|
||||||
|
if (_disposed || !identical(_peerService, peerService)) return;
|
||||||
_handleSyncMessage(message);
|
_handleSyncMessage(message);
|
||||||
});
|
});
|
||||||
|
|
||||||
_errorSubscription = _peerService!.onError.listen((error) {
|
_errorSubscription = peerService.onError.listen((error) {
|
||||||
|
if (_disposed || !identical(_peerService, peerService)) return;
|
||||||
|
final hostPeerId = _session?.hostPeerId;
|
||||||
|
if (error.serverCode == 'not_in_room' &&
|
||||||
|
!isHost &&
|
||||||
|
hostPeerId != null &&
|
||||||
|
!peerService.connectedPeers.contains(hostPeerId)) {
|
||||||
|
appLogger.d('WatchTogether: Declared host is not connected yet; keeping the retained-room join pending');
|
||||||
|
return;
|
||||||
|
}
|
||||||
appLogger.e('WatchTogether: Peer error: ${error.message}');
|
appLogger.e('WatchTogether: Peer error: ${error.message}');
|
||||||
|
|
||||||
// Update session state on error
|
// Only established transport stream errors are recoverable. Relay and
|
||||||
|
// terminal errors must survive any later reconnect callback.
|
||||||
if (_session != null && _session!.state == SessionState.connected) {
|
if (_session != null && _session!.state == SessionState.connected) {
|
||||||
|
_recoverableTransportError = error.originalError != null && error.serverCode == null ? error : null;
|
||||||
_session = _session!.copyWith(state: SessionState.error, errorMessage: error.message);
|
_session = _session!.copyWith(state: SessionState.error, errorMessage: error.message);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
_sessionEndedSubscription = peerService.onSessionEnded.listen((_) {
|
||||||
|
if (_disposed || !identical(_peerService, peerService) || isHost) return;
|
||||||
|
appLogger.d('WatchTogether: Relay confirmed that the host ended the room');
|
||||||
|
_hostIntentionallyLeft = true;
|
||||||
|
_handleHostExitedPlayer();
|
||||||
|
final detachedPeerService = _detachLocalSession(announceLeave: false);
|
||||||
|
if (detachedPeerService != null) {
|
||||||
|
unawaited(
|
||||||
|
_finishPeerTeardown(detachedPeerService, release: false).catchError((Object error, StackTrace stackTrace) {
|
||||||
|
appLogger.e('WatchTogether: Failed to close an ended relay session', error: error, stackTrace: stackTrace);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle incoming sync messages for participant management
|
/// Handle incoming sync messages for participant management
|
||||||
@@ -622,7 +759,7 @@ class WatchTogetherProvider with ChangeNotifier {
|
|||||||
if (!isHost && message.peerId == _session?.hostPeerId) {
|
if (!isHost && message.peerId == _session?.hostPeerId) {
|
||||||
_hostIntentionallyLeft = true;
|
_hostIntentionallyLeft = true;
|
||||||
_handleHostExitedPlayer();
|
_handleHostExitedPlayer();
|
||||||
leaveSession();
|
_leaveSessionBestEffort('host-leave cleanup');
|
||||||
}
|
}
|
||||||
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -772,6 +909,7 @@ class WatchTogetherProvider with ChangeNotifier {
|
|||||||
if (_isWaitingForHostReconnect) {
|
if (_isWaitingForHostReconnect) {
|
||||||
appLogger.d('WatchTogether: Host reconnect grace period expired');
|
appLogger.d('WatchTogether: Host reconnect grace period expired');
|
||||||
_isWaitingForHostReconnect = false;
|
_isWaitingForHostReconnect = false;
|
||||||
|
_recoverableTransportError = null;
|
||||||
_session = _session?.copyWith(state: SessionState.error, errorMessage: 'Host left the session');
|
_session = _session?.copyWith(state: SessionState.error, errorMessage: 'Host left the session');
|
||||||
onHostExitedPlayer?.call();
|
onHostExitedPlayer?.call();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -792,11 +930,18 @@ class WatchTogetherProvider with ChangeNotifier {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
if (_disposed) return;
|
||||||
_disposed = true;
|
_disposed = true;
|
||||||
_cancelHostReconnectGracePeriod();
|
final peerService = _detachLocalSession(announceLeave: true);
|
||||||
_participantEventController.close();
|
unawaited(_participantEventController.close());
|
||||||
leaveSession();
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
|
if (peerService != null) {
|
||||||
|
unawaited(
|
||||||
|
_finishPeerTeardown(peerService, release: true).catchError((Object error, StackTrace stackTrace) {
|
||||||
|
appLogger.e('WatchTogether: Failed to release session during dispose', error: error, stackTrace: stackTrace);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import '../../widgets/overlay_sheet.dart';
|
|||||||
import '../models/watch_session.dart';
|
import '../models/watch_session.dart';
|
||||||
import '../providers/watch_together_provider.dart';
|
import '../providers/watch_together_provider.dart';
|
||||||
import '../services/recent_rooms_service.dart';
|
import '../services/recent_rooms_service.dart';
|
||||||
import '../services/watch_together_peer_service.dart';
|
import '../services/watch_together_relay_endpoint.dart';
|
||||||
import '../widgets/join_session_dialog.dart';
|
import '../widgets/join_session_dialog.dart';
|
||||||
import '../../widgets/loading_indicator_box.dart';
|
import '../../widgets/loading_indicator_box.dart';
|
||||||
|
|
||||||
@@ -88,14 +88,18 @@ class _NotInSessionViewState extends State<_NotInSessionView> with MountedSetSta
|
|||||||
bool _isJoining = false;
|
bool _isJoining = false;
|
||||||
String? _enteringRoomCode;
|
String? _enteringRoomCode;
|
||||||
bool? _healthOk;
|
bool? _healthOk;
|
||||||
String? _customRelayUrl;
|
String? _profileId;
|
||||||
|
late WatchTogetherRelayEndpoint _relayEndpoint;
|
||||||
List<RecentRoom> _recentRooms = [];
|
List<RecentRoom> _recentRooms = [];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_customRelayUrl = SettingsService.instanceOrNull?.read(SettingsService.customRelayUrl);
|
_profileId = context.read<ActiveProfileProvider>().activeId;
|
||||||
_recentRooms = RecentRoomsService.getRecentRooms();
|
_relayEndpoint = WatchTogetherRelayEndpoint.resolve(
|
||||||
|
SettingsService.instanceOrNull?.read(SettingsService.customRelayUrl),
|
||||||
|
);
|
||||||
|
_recentRooms = _loadRecentRooms();
|
||||||
_checkHealth();
|
_checkHealth();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,11 +107,17 @@ class _NotInSessionViewState extends State<_NotInSessionView> with MountedSetSta
|
|||||||
|
|
||||||
String? get _plexDisplayName => context.read<ActiveProfileProvider>().active?.displayName;
|
String? get _plexDisplayName => context.read<ActiveProfileProvider>().active?.displayName;
|
||||||
|
|
||||||
|
List<RecentRoom> _loadRecentRooms() {
|
||||||
|
final profileId = _profileId;
|
||||||
|
if (profileId == null || profileId.isEmpty) return const [];
|
||||||
|
return RecentRoomsService.getRecentRooms(profileId: profileId, endpoint: _relayEndpoint);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _checkHealth() async {
|
Future<void> _checkHealth() async {
|
||||||
final client = HttpClient();
|
final client = HttpClient();
|
||||||
try {
|
try {
|
||||||
client.connectionTimeout = const Duration(seconds: 5);
|
client.connectionTimeout = const Duration(seconds: 5);
|
||||||
final request = await client.getUrl(Uri.parse(WatchTogetherPeerService.healthUrlFor(_customRelayUrl)));
|
final request = await client.getUrl(_relayEndpoint.healthUri);
|
||||||
final response = await request.close().namedTimeout(
|
final response = await request.close().namedTimeout(
|
||||||
const Duration(seconds: 5),
|
const Duration(seconds: 5),
|
||||||
operation: 'WatchTogether health check',
|
operation: 'WatchTogether health check',
|
||||||
@@ -226,10 +236,19 @@ class _NotInSessionViewState extends State<_NotInSessionView> with MountedSetSta
|
|||||||
try {
|
try {
|
||||||
final sessionId = await widget.watchTogether.createSession(
|
final sessionId = await widget.watchTogether.createSession(
|
||||||
controlMode: controlMode,
|
controlMode: controlMode,
|
||||||
|
relayEndpoint: _relayEndpoint,
|
||||||
displayName: _plexDisplayName,
|
displayName: _plexDisplayName,
|
||||||
);
|
);
|
||||||
await RecentRoomsService.addOrUpdateRoom(sessionId, controlMode: controlMode);
|
final profileId = _profileId;
|
||||||
setStateIfMounted(() => _recentRooms = RecentRoomsService.getRecentRooms());
|
if (profileId != null && profileId.isNotEmpty) {
|
||||||
|
await RecentRoomsService.addOrUpdateRoom(
|
||||||
|
sessionId,
|
||||||
|
profileId: profileId,
|
||||||
|
endpoint: _relayEndpoint,
|
||||||
|
controlMode: controlMode,
|
||||||
|
);
|
||||||
|
setStateIfMounted(() => _recentRooms = _loadRecentRooms());
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
appLogger.e('Failed to create session', error: e);
|
appLogger.e('Failed to create session', error: e);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -271,9 +290,12 @@ class _NotInSessionViewState extends State<_NotInSessionView> with MountedSetSta
|
|||||||
setState(() => _isJoining = true);
|
setState(() => _isJoining = true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await widget.watchTogether.joinSession(sessionId, displayName: _plexDisplayName);
|
await widget.watchTogether.joinSession(sessionId, relayEndpoint: _relayEndpoint, displayName: _plexDisplayName);
|
||||||
await RecentRoomsService.addOrUpdateRoom(sessionId);
|
final profileId = _profileId;
|
||||||
setStateIfMounted(() => _recentRooms = RecentRoomsService.getRecentRooms());
|
if (profileId != null && profileId.isNotEmpty) {
|
||||||
|
await RecentRoomsService.addOrUpdateRoom(sessionId, profileId: profileId, endpoint: _relayEndpoint);
|
||||||
|
setStateIfMounted(() => _recentRooms = _loadRecentRooms());
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
appLogger.e('Failed to join session', error: e);
|
appLogger.e('Failed to join session', error: e);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -292,11 +314,15 @@ class _NotInSessionViewState extends State<_NotInSessionView> with MountedSetSta
|
|||||||
try {
|
try {
|
||||||
await widget.watchTogether.enterRoom(
|
await widget.watchTogether.enterRoom(
|
||||||
room.code,
|
room.code,
|
||||||
|
relayEndpoint: _relayEndpoint,
|
||||||
controlMode: room.controlMode ?? ControlMode.anyone,
|
controlMode: room.controlMode ?? ControlMode.anyone,
|
||||||
displayName: _plexDisplayName,
|
displayName: _plexDisplayName,
|
||||||
);
|
);
|
||||||
await RecentRoomsService.addOrUpdateRoom(room.code);
|
final profileId = _profileId;
|
||||||
setStateIfMounted(() => _recentRooms = RecentRoomsService.getRecentRooms());
|
if (profileId != null && profileId.isNotEmpty) {
|
||||||
|
await RecentRoomsService.addOrUpdateRoom(room.code, profileId: profileId, endpoint: _relayEndpoint);
|
||||||
|
setStateIfMounted(() => _recentRooms = _loadRecentRooms());
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
appLogger.e('Failed to enter room', error: e);
|
appLogger.e('Failed to enter room', error: e);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -316,13 +342,22 @@ class _NotInSessionViewState extends State<_NotInSessionView> with MountedSetSta
|
|||||||
);
|
);
|
||||||
if (name == null || !mounted) return;
|
if (name == null || !mounted) return;
|
||||||
|
|
||||||
await RecentRoomsService.renameRoom(room.code, name.isEmpty ? null : name);
|
final profileId = _profileId;
|
||||||
setStateIfMounted(() => _recentRooms = RecentRoomsService.getRecentRooms());
|
if (profileId == null || profileId.isEmpty) return;
|
||||||
|
await RecentRoomsService.renameRoom(
|
||||||
|
room.code,
|
||||||
|
name.isEmpty ? null : name,
|
||||||
|
profileId: profileId,
|
||||||
|
endpoint: _relayEndpoint,
|
||||||
|
);
|
||||||
|
setStateIfMounted(() => _recentRooms = _loadRecentRooms());
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _removeRoom(RecentRoom room) async {
|
Future<void> _removeRoom(RecentRoom room) async {
|
||||||
await RecentRoomsService.removeRoom(room.code);
|
final profileId = _profileId;
|
||||||
setStateIfMounted(() => _recentRooms = RecentRoomsService.getRecentRooms());
|
if (profileId == null || profileId.isEmpty) return;
|
||||||
|
await RecentRoomsService.removeRoom(room.code, profileId: profileId, endpoint: _relayEndpoint);
|
||||||
|
setStateIfMounted(() => _recentRooms = _loadRecentRooms());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -633,8 +668,11 @@ class _ActiveSessionContent extends StatelessWidget {
|
|||||||
isDestructive: true,
|
isDestructive: true,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (confirmed) {
|
if (!confirmed) return;
|
||||||
|
try {
|
||||||
await watchTogether.leaveSession();
|
await watchTogether.leaveSession();
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
appLogger.e('WatchTogether: Session leave failed', error: error, stackTrace: stackTrace);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ class HostPlaybackCoordinator {
|
|||||||
static const int seekDebounceMs = 200;
|
static const int seekDebounceMs = 200;
|
||||||
static const int implicitJumpThresholdMs = 1500;
|
static const int implicitJumpThresholdMs = 1500;
|
||||||
static const int selfRecoveryMinBufferAheadMs = 2000;
|
static const int selfRecoveryMinBufferAheadMs = 2000;
|
||||||
|
static const double _minimumRemoteRate = 0.25;
|
||||||
|
static const double _maximumRemoteRate = 4.0;
|
||||||
|
|
||||||
final String myPeerId;
|
final String myPeerId;
|
||||||
final void Function(PlaybackState state, {String? toPeerId}) _sendState;
|
final void Function(PlaybackState state, {String? toPeerId}) _sendState;
|
||||||
@@ -496,7 +498,9 @@ class HostPlaybackCoordinator {
|
|||||||
|
|
||||||
void _applyRemoteSeek(int targetMs, {required String actor}) {
|
void _applyRemoteSeek(int targetMs, {required String actor}) {
|
||||||
final player = _player;
|
final player = _player;
|
||||||
if (player == null) return;
|
if (player == null || !player.seekable) return;
|
||||||
|
final durationMs = player.duration.inMilliseconds;
|
||||||
|
if (durationMs <= 0 || targetMs < 0 || targetMs > durationMs) return;
|
||||||
_callbacks.onRemoteAction?.call(actor, PlaybackActionHint.seek);
|
_callbacks.onRemoteAction?.call(actor, PlaybackActionHint.seek);
|
||||||
unawaited(
|
unawaited(
|
||||||
player.seek(Duration(milliseconds: targetMs)).then((didSeek) {
|
player.seek(Duration(milliseconds: targetMs)).then((didSeek) {
|
||||||
@@ -516,7 +520,7 @@ class HostPlaybackCoordinator {
|
|||||||
|
|
||||||
void _applyRemoteRate(double rate, {required String actor}) {
|
void _applyRemoteRate(double rate, {required String actor}) {
|
||||||
final player = _player;
|
final player = _player;
|
||||||
if (player == null) return;
|
if (player == null || !rate.isFinite || rate < _minimumRemoteRate || rate > _maximumRemoteRate) return;
|
||||||
_callbacks.onRemoteAction?.call(actor, PlaybackActionHint.rate);
|
_callbacks.onRemoteAction?.call(actor, PlaybackActionHint.rate);
|
||||||
unawaited(
|
unawaited(
|
||||||
player.setRate(rate).then((didSet) {
|
player.setRate(rate).then((didSet) {
|
||||||
|
|||||||
@@ -1,22 +1,26 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:json_annotation/json_annotation.dart';
|
import 'package:json_annotation/json_annotation.dart';
|
||||||
|
import 'package:crypto/crypto.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
import '../../services/settings_service.dart';
|
import '../../services/settings_service.dart';
|
||||||
import '../models/watch_session.dart';
|
import '../models/watch_session.dart';
|
||||||
|
import 'watch_together_relay_endpoint.dart';
|
||||||
|
|
||||||
part 'recent_rooms_service.g.dart';
|
part 'recent_rooms_service.g.dart';
|
||||||
|
|
||||||
@JsonSerializable(includeIfNull: false)
|
@JsonSerializable(includeIfNull: false)
|
||||||
class RecentRoom {
|
class RecentRoom {
|
||||||
final String code;
|
final String code;
|
||||||
|
final String relayScope;
|
||||||
final String? name;
|
final String? name;
|
||||||
@JsonKey(fromJson: _dateTimeFromMillis, toJson: _dateTimeToMillis)
|
@JsonKey(fromJson: _dateTimeFromMillis, toJson: _dateTimeToMillis)
|
||||||
final DateTime lastUsed;
|
final DateTime lastUsed;
|
||||||
@JsonKey(fromJson: _controlModeFromIndex, toJson: _controlModeToIndex)
|
@JsonKey(fromJson: _controlModeFromIndex, toJson: _controlModeToIndex)
|
||||||
final ControlMode? controlMode;
|
final ControlMode? controlMode;
|
||||||
|
|
||||||
const RecentRoom({required this.code, this.name, required this.lastUsed, this.controlMode});
|
const RecentRoom({required this.code, required this.relayScope, this.name, required this.lastUsed, this.controlMode});
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => _$RecentRoomToJson(this);
|
Map<String, dynamic> toJson() => _$RecentRoomToJson(this);
|
||||||
|
|
||||||
@@ -24,12 +28,14 @@ class RecentRoom {
|
|||||||
|
|
||||||
RecentRoom copyWith({
|
RecentRoom copyWith({
|
||||||
String? code,
|
String? code,
|
||||||
|
String? relayScope,
|
||||||
String? name,
|
String? name,
|
||||||
DateTime? lastUsed,
|
DateTime? lastUsed,
|
||||||
ControlMode? controlMode,
|
ControlMode? controlMode,
|
||||||
bool clearName = false,
|
bool clearName = false,
|
||||||
}) => RecentRoom(
|
}) => RecentRoom(
|
||||||
code: code ?? this.code,
|
code: code ?? this.code,
|
||||||
|
relayScope: relayScope ?? this.relayScope,
|
||||||
name: clearName ? null : (name ?? this.name),
|
name: clearName ? null : (name ?? this.name),
|
||||||
lastUsed: lastUsed ?? this.lastUsed,
|
lastUsed: lastUsed ?? this.lastUsed,
|
||||||
controlMode: controlMode ?? this.controlMode,
|
controlMode: controlMode ?? this.controlMode,
|
||||||
@@ -50,12 +56,19 @@ int? _controlModeToIndex(ControlMode? value) => value?.index;
|
|||||||
class RecentRoomsService {
|
class RecentRoomsService {
|
||||||
static const int _maxRooms = 20;
|
static const int _maxRooms = 20;
|
||||||
|
|
||||||
static List<RecentRoom> getRecentRooms() {
|
static List<RecentRoom> getRecentRooms({required String profileId, required WatchTogetherRelayEndpoint endpoint}) {
|
||||||
final json = SettingsService.instanceOrNull?.read(SettingsService.recentRooms);
|
final scope = _scopeFor(endpoint);
|
||||||
|
return _load(profileId).where((room) => room.relayScope == scope).toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<RecentRoom> _load(String profileId) {
|
||||||
|
final settings = SettingsService.instanceOrNull;
|
||||||
|
if (settings == null) return [];
|
||||||
|
final json = settings.read(SettingsService.recentRoomsForProfile(profileId));
|
||||||
if (json == null) return [];
|
if (json == null) return [];
|
||||||
try {
|
try {
|
||||||
final list = jsonDecode(json) as List<dynamic>;
|
final list = jsonDecode(json) as List<dynamic>;
|
||||||
final rooms = list.map((e) => RecentRoom.fromJson(e as Map<String, dynamic>)).toList();
|
final rooms = list.map((entry) => RecentRoom.fromJson(entry as Map<String, dynamic>)).toList();
|
||||||
rooms.sort((a, b) => b.lastUsed.compareTo(a.lastUsed));
|
rooms.sort((a, b) => b.lastUsed.compareTo(a.lastUsed));
|
||||||
return rooms;
|
return rooms;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
@@ -63,18 +76,27 @@ class RecentRoomsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> _save(List<RecentRoom> rooms) async {
|
static Future<void> _save(String profileId, List<RecentRoom> rooms) async {
|
||||||
rooms.sort((a, b) => b.lastUsed.compareTo(a.lastUsed));
|
rooms.sort((a, b) => b.lastUsed.compareTo(a.lastUsed));
|
||||||
if (rooms.length > _maxRooms) rooms.removeRange(_maxRooms, rooms.length);
|
if (rooms.length > _maxRooms) {
|
||||||
|
rooms.removeRange(_maxRooms, rooms.length);
|
||||||
|
}
|
||||||
await SettingsService.instanceOrNull?.write(
|
await SettingsService.instanceOrNull?.write(
|
||||||
SettingsService.recentRooms,
|
SettingsService.recentRoomsForProfile(profileId),
|
||||||
jsonEncode(rooms.map((r) => r.toJson()).toList()),
|
jsonEncode(rooms.map((room) => room.toJson()).toList()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> addOrUpdateRoom(String code, {String? name, ControlMode? controlMode}) async {
|
static Future<void> addOrUpdateRoom(
|
||||||
final rooms = getRecentRooms();
|
String code, {
|
||||||
final index = rooms.indexWhere((r) => r.code == code);
|
required String profileId,
|
||||||
|
required WatchTogetherRelayEndpoint endpoint,
|
||||||
|
String? name,
|
||||||
|
ControlMode? controlMode,
|
||||||
|
}) async {
|
||||||
|
final scope = _scopeFor(endpoint);
|
||||||
|
final rooms = _load(profileId);
|
||||||
|
final index = rooms.indexWhere((room) => room.relayScope == scope && room.code == code);
|
||||||
if (index >= 0) {
|
if (index >= 0) {
|
||||||
rooms[index] = rooms[index].copyWith(
|
rooms[index] = rooms[index].copyWith(
|
||||||
lastUsed: DateTime.now(),
|
lastUsed: DateTime.now(),
|
||||||
@@ -82,23 +104,41 @@ class RecentRoomsService {
|
|||||||
controlMode: controlMode,
|
controlMode: controlMode,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
rooms.add(RecentRoom(code: code, name: name, lastUsed: DateTime.now(), controlMode: controlMode));
|
rooms.add(
|
||||||
|
RecentRoom(code: code, relayScope: scope, name: name, lastUsed: DateTime.now(), controlMode: controlMode),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
await _save(rooms);
|
await _save(profileId, rooms);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> removeRoom(String code) async {
|
static Future<void> removeRoom(
|
||||||
final rooms = getRecentRooms();
|
String code, {
|
||||||
rooms.removeWhere((r) => r.code == code);
|
required String profileId,
|
||||||
await _save(rooms);
|
required WatchTogetherRelayEndpoint endpoint,
|
||||||
|
}) async {
|
||||||
|
final scope = _scopeFor(endpoint);
|
||||||
|
final rooms = _load(profileId)..removeWhere((room) => room.relayScope == scope && room.code == code);
|
||||||
|
await _save(profileId, rooms);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> renameRoom(String code, String? name) async {
|
static Future<void> renameRoom(
|
||||||
final rooms = getRecentRooms();
|
String code,
|
||||||
final index = rooms.indexWhere((r) => r.code == code);
|
String? name, {
|
||||||
|
required String profileId,
|
||||||
|
required WatchTogetherRelayEndpoint endpoint,
|
||||||
|
}) async {
|
||||||
|
final scope = _scopeFor(endpoint);
|
||||||
|
final rooms = _load(profileId);
|
||||||
|
final index = rooms.indexWhere((room) => room.relayScope == scope && room.code == code);
|
||||||
if (index >= 0) {
|
if (index >= 0) {
|
||||||
rooms[index] = rooms[index].copyWith(name: name, clearName: name == null);
|
rooms[index] = rooms[index].copyWith(name: name, clearName: name == null);
|
||||||
await _save(rooms);
|
await _save(profileId, rooms);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
static String relayScopeFor(WatchTogetherRelayEndpoint endpoint) => _scopeFor(endpoint);
|
||||||
|
|
||||||
|
static String _scopeFor(WatchTogetherRelayEndpoint endpoint) =>
|
||||||
|
sha256.convert(utf8.encode(endpoint.canonicalBaseUrl)).toString();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ part of 'recent_rooms_service.dart';
|
|||||||
|
|
||||||
RecentRoom _$RecentRoomFromJson(Map<String, dynamic> json) => RecentRoom(
|
RecentRoom _$RecentRoomFromJson(Map<String, dynamic> json) => RecentRoom(
|
||||||
code: json['code'] as String,
|
code: json['code'] as String,
|
||||||
|
relayScope: json['relayScope'] as String,
|
||||||
name: json['name'] as String?,
|
name: json['name'] as String?,
|
||||||
lastUsed: _dateTimeFromMillis((json['lastUsed'] as num).toInt()),
|
lastUsed: _dateTimeFromMillis((json['lastUsed'] as num).toInt()),
|
||||||
controlMode: _controlModeFromIndex((json['controlMode'] as num?)?.toInt()),
|
controlMode: _controlModeFromIndex((json['controlMode'] as num?)?.toInt()),
|
||||||
@@ -16,6 +17,7 @@ RecentRoom _$RecentRoomFromJson(Map<String, dynamic> json) => RecentRoom(
|
|||||||
Map<String, dynamic> _$RecentRoomToJson(RecentRoom instance) =>
|
Map<String, dynamic> _$RecentRoomToJson(RecentRoom instance) =>
|
||||||
<String, dynamic>{
|
<String, dynamic>{
|
||||||
'code': instance.code,
|
'code': instance.code,
|
||||||
|
'relayScope': instance.relayScope,
|
||||||
'name': ?instance.name,
|
'name': ?instance.name,
|
||||||
'lastUsed': _dateTimeToMillis(instance.lastUsed),
|
'lastUsed': _dateTimeToMillis(instance.lastUsed),
|
||||||
'controlMode': ?_controlModeToIndex(instance.controlMode),
|
'controlMode': ?_controlModeToIndex(instance.controlMode),
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
// Generated by scripts/generate_relay_protocol.py. Do not edit.
|
// Generated by scripts/generate_relay_protocol.py. Do not edit.
|
||||||
|
|
||||||
abstract final class RelayProtocol {
|
abstract final class RelayProtocol {
|
||||||
|
static const int protocolVersion = 2;
|
||||||
|
static const int legacyProtocolVersion = 0;
|
||||||
|
|
||||||
static const String create = 'create';
|
static const String create = 'create';
|
||||||
static const String join = 'join';
|
static const String join = 'join';
|
||||||
static const String broadcast = 'broadcast';
|
static const String broadcast = 'broadcast';
|
||||||
static const String sendTo = 'sendTo';
|
static const String sendTo = 'sendTo';
|
||||||
static const String ping = 'ping';
|
static const String ping = 'ping';
|
||||||
|
static const String leave = 'leave';
|
||||||
|
static const String endSession = 'endSession';
|
||||||
static const String created = 'created';
|
static const String created = 'created';
|
||||||
static const String joined = 'joined';
|
static const String joined = 'joined';
|
||||||
static const String peerJoined = 'peerJoined';
|
static const String peerJoined = 'peerJoined';
|
||||||
@@ -13,6 +18,8 @@ abstract final class RelayProtocol {
|
|||||||
static const String message = 'message';
|
static const String message = 'message';
|
||||||
static const String error = 'error';
|
static const String error = 'error';
|
||||||
static const String pong = 'pong';
|
static const String pong = 'pong';
|
||||||
|
static const String left = 'left';
|
||||||
|
static const String ended = 'ended';
|
||||||
static const String rateLimitedCode = 'rate_limited';
|
static const String rateLimitedCode = 'rate_limited';
|
||||||
static const String invalidMessageCode = 'invalid_message';
|
static const String invalidMessageCode = 'invalid_message';
|
||||||
static const String roomExistsCode = 'room_exists';
|
static const String roomExistsCode = 'room_exists';
|
||||||
@@ -20,6 +27,8 @@ abstract final class RelayProtocol {
|
|||||||
static const String roomFullCode = 'room_full';
|
static const String roomFullCode = 'room_full';
|
||||||
static const String notInRoomCode = 'not_in_room';
|
static const String notInRoomCode = 'not_in_room';
|
||||||
static const String alreadyInRoomCode = 'already_in_room';
|
static const String alreadyInRoomCode = 'already_in_room';
|
||||||
|
static const String peerIdUnavailableCode = 'peer_id_unavailable';
|
||||||
|
static const String protocolMismatchCode = 'protocol_mismatch';
|
||||||
|
|
||||||
static const int maxRoomSize = 8;
|
static const int maxRoomSize = 8;
|
||||||
static const int maxMessageSize = 65536;
|
static const int maxMessageSize = 65536;
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import 'watch_together_peer_service.dart';
|
|||||||
/// switches or other attach gaps — the player attachment is just an output
|
/// switches or other attach gaps — the player attachment is just an output
|
||||||
/// binding the role engine reconciles against.
|
/// binding the role engine reconciles against.
|
||||||
///
|
///
|
||||||
/// Routes the v2 protocol between the relay and the role engine:
|
/// Routes the v3 protocol between the relay and the role engine:
|
||||||
/// host → [HostPlaybackCoordinator] (single writer of [PlaybackState]),
|
/// host → [HostPlaybackCoordinator] (single writer of [PlaybackState]),
|
||||||
/// guest → [GuestPlaybackReconciler] (+ [ClockSync] against the host).
|
/// guest → [GuestPlaybackReconciler] (+ [ClockSync] against the host).
|
||||||
class WatchTogetherController {
|
class WatchTogetherController {
|
||||||
@@ -165,7 +165,11 @@ class WatchTogetherController {
|
|||||||
_attachedPlayer = null;
|
_attachedPlayer = null;
|
||||||
_coordinator?.detachPlayer(exiting: exiting);
|
_coordinator?.detachPlayer(exiting: exiting);
|
||||||
_reconciler?.detachPlayer();
|
_reconciler?.detachPlayer();
|
||||||
unawaited(attached.dispose());
|
unawaited(
|
||||||
|
attached.dispose().catchError((Object error, StackTrace stackTrace) {
|
||||||
|
appLogger.e('WatchTogether: Failed to detach player subscriptions', error: error, stackTrace: stackTrace);
|
||||||
|
}),
|
||||||
|
);
|
||||||
appLogger.d('WatchTogether: Player detached (exiting: $exiting)');
|
appLogger.d('WatchTogether: Player detached (exiting: $exiting)');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,7 +235,11 @@ class WatchTogetherController {
|
|||||||
_disposed = true;
|
_disposed = true;
|
||||||
detachPlayer(exiting: true);
|
detachPlayer(exiting: true);
|
||||||
for (final subscription in _subscriptions) {
|
for (final subscription in _subscriptions) {
|
||||||
unawaited(subscription.cancel());
|
unawaited(
|
||||||
|
subscription.cancel().catchError((Object error, StackTrace stackTrace) {
|
||||||
|
appLogger.e('WatchTogether: Failed to cancel controller subscription', error: error, stackTrace: stackTrace);
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
_subscriptions.clear();
|
_subscriptions.clear();
|
||||||
_clockSync?.stop();
|
_clockSync?.stop();
|
||||||
|
|||||||
@@ -10,12 +10,17 @@ import '../../i18n/strings.g.dart';
|
|||||||
import '../../services/base_peer_service.dart';
|
import '../../services/base_peer_service.dart';
|
||||||
import '../../utils/app_logger.dart';
|
import '../../utils/app_logger.dart';
|
||||||
import '../models/sync_message.dart';
|
import '../models/sync_message.dart';
|
||||||
import '../primitives.dart';
|
|
||||||
import 'relay_protocol.g.dart';
|
import 'relay_protocol.g.dart';
|
||||||
|
import 'watch_together_relay_endpoint.dart';
|
||||||
|
|
||||||
// Re-export so existing callers that import from here keep working.
|
// Re-export so existing callers that import from here keep working.
|
||||||
export '../../services/base_peer_service.dart' show PeerError, PeerErrorType;
|
export '../../services/base_peer_service.dart' show PeerError, PeerErrorType;
|
||||||
|
|
||||||
|
class _PinnedHostChangedError extends PeerError {
|
||||||
|
const _PinnedHostChangedError()
|
||||||
|
: super(type: PeerErrorType.serverError, message: 'Relay returned an invalid joined response');
|
||||||
|
}
|
||||||
|
|
||||||
/// Service for managing Watch Together connections via a WebSocket relay
|
/// Service for managing Watch Together connections via a WebSocket relay
|
||||||
///
|
///
|
||||||
/// This service handles:
|
/// This service handles:
|
||||||
@@ -24,32 +29,31 @@ export '../../services/base_peer_service.dart' show PeerError, PeerErrorType;
|
|||||||
/// - Sending/receiving sync messages through the relay server
|
/// - Sending/receiving sync messages through the relay server
|
||||||
/// - Reconnection on WebSocket drops
|
/// - Reconnection on WebSocket drops
|
||||||
class WatchTogetherPeerService with KeepaliveMixin {
|
class WatchTogetherPeerService with KeepaliveMixin {
|
||||||
static const String defaultBaseUrl = 'https://ice.plezy.app';
|
final WatchTogetherRelayEndpoint endpoint;
|
||||||
|
final WebSocketChannel Function(Uri uri) _channelFactory;
|
||||||
|
|
||||||
final String _baseUrl;
|
/// Test synchronization point after relay setup succeeds but before the
|
||||||
|
/// reconnect is published to consumers.
|
||||||
|
final Future<void> Function()? debugReconnectSetupSucceededBarrier;
|
||||||
|
|
||||||
static String get healthUrl => '$defaultBaseUrl/health';
|
WatchTogetherPeerService({
|
||||||
|
WatchTogetherRelayEndpoint? endpoint,
|
||||||
static String healthUrlFor(String? customBaseUrl) {
|
this.debugReconnectSetupSucceededBarrier,
|
||||||
final base = (customBaseUrl != null && customBaseUrl.trim().isNotEmpty) ? customBaseUrl.trim() : defaultBaseUrl;
|
WebSocketChannel Function(Uri uri)? debugChannelFactory,
|
||||||
return '$base/health';
|
}) : endpoint = endpoint ?? WatchTogetherRelayEndpoint.defaultEndpoint,
|
||||||
}
|
_channelFactory = debugChannelFactory ?? ((uri) => WebSocketChannel.connect(uri));
|
||||||
|
static const int _relayProtocolVersion = RelayProtocol.protocolVersion;
|
||||||
String get _relayUrl {
|
|
||||||
final wsBase = _baseUrl.replaceFirst(RegExp(r'^https://'), 'wss://').replaceFirst(RegExp(r'^http://'), 'ws://');
|
|
||||||
return '$wsBase/relay';
|
|
||||||
}
|
|
||||||
|
|
||||||
WatchTogetherPeerService({String? customBaseUrl})
|
|
||||||
: _baseUrl = (customBaseUrl != null && customBaseUrl.trim().isNotEmpty) ? customBaseUrl.trim() : defaultBaseUrl;
|
|
||||||
|
|
||||||
WebSocketChannel? _channel;
|
WebSocketChannel? _channel;
|
||||||
StreamSubscription? _channelSubscription;
|
StreamSubscription? _channelSubscription;
|
||||||
Completer<void>? _setupCompleter;
|
Completer<void>? _setupCompleter;
|
||||||
|
String? _setupRequestType;
|
||||||
final Set<String> _connectedPeers = {};
|
final Set<String> _connectedPeers = {};
|
||||||
String? _sessionId;
|
String? _sessionId;
|
||||||
String? _myPeerId;
|
String? _myPeerId;
|
||||||
bool _isHost = false;
|
bool _isHost = false;
|
||||||
|
String? _reconnectToken;
|
||||||
|
String? _hostPeerId;
|
||||||
|
|
||||||
// Stream controllers for events
|
// Stream controllers for events
|
||||||
final _peerConnectedController = StreamController<String>.broadcast();
|
final _peerConnectedController = StreamController<String>.broadcast();
|
||||||
@@ -57,6 +61,7 @@ class WatchTogetherPeerService with KeepaliveMixin {
|
|||||||
final _messageReceivedController = StreamController<SyncMessage>.broadcast();
|
final _messageReceivedController = StreamController<SyncMessage>.broadcast();
|
||||||
final _errorController = StreamController<PeerError>.broadcast();
|
final _errorController = StreamController<PeerError>.broadcast();
|
||||||
final _connectionStateController = StreamController<bool>.broadcast();
|
final _connectionStateController = StreamController<bool>.broadcast();
|
||||||
|
final _sessionEndedController = StreamController<void>.broadcast();
|
||||||
|
|
||||||
// Reconnection state
|
// Reconnection state
|
||||||
int _reconnectAttempts = 0;
|
int _reconnectAttempts = 0;
|
||||||
@@ -64,6 +69,9 @@ class WatchTogetherPeerService with KeepaliveMixin {
|
|||||||
Timer? _reconnectTimer;
|
Timer? _reconnectTimer;
|
||||||
int _connectionEpoch = 0;
|
int _connectionEpoch = 0;
|
||||||
bool _disposed = false;
|
bool _disposed = false;
|
||||||
|
bool _initialSetupInProgress = false;
|
||||||
|
bool _teardownInProgress = false;
|
||||||
|
Future<void>? _releaseFuture;
|
||||||
|
|
||||||
/// Called after a successful reconnection so the provider can re-announce join.
|
/// Called after a successful reconnection so the provider can re-announce join.
|
||||||
void Function()? onReconnected;
|
void Function()? onReconnected;
|
||||||
@@ -93,12 +101,18 @@ class WatchTogetherPeerService with KeepaliveMixin {
|
|||||||
/// Stream of connection state changes (true = connected, false = disconnected)
|
/// Stream of connection state changes (true = connected, false = disconnected)
|
||||||
Stream<bool> get onConnectionStateChanged => _connectionStateController.stream;
|
Stream<bool> get onConnectionStateChanged => _connectionStateController.stream;
|
||||||
|
|
||||||
|
/// Emitted when the host has durably ended the relay room.
|
||||||
|
Stream<void> get onSessionEnded => _sessionEndedController.stream;
|
||||||
|
|
||||||
/// Current session ID (null if not in a session)
|
/// Current session ID (null if not in a session)
|
||||||
String? get sessionId => _sessionId;
|
String? get sessionId => _sessionId;
|
||||||
|
|
||||||
/// This peer's ID
|
/// This peer's ID
|
||||||
String? get myPeerId => _myPeerId;
|
String? get myPeerId => _myPeerId;
|
||||||
|
|
||||||
|
/// Relay-declared peer ID whose messages carry host authority.
|
||||||
|
String? get hostPeerId => _hostPeerId;
|
||||||
|
|
||||||
/// Whether this peer is the host
|
/// Whether this peer is the host
|
||||||
bool get isHost => _isHost;
|
bool get isHost => _isHost;
|
||||||
|
|
||||||
@@ -115,21 +129,45 @@ class WatchTogetherPeerService with KeepaliveMixin {
|
|||||||
return String.fromCharCodes(List.generate(5, (_) => chars.codeUnitAt(random.nextInt(chars.length))));
|
return String.fromCharCodes(List.generate(5, (_) => chars.codeUnitAt(random.nextInt(chars.length))));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mint the reconnect capability before setup so a lost setup ACK can be
|
||||||
|
/// retried without relying on server-returned state.
|
||||||
|
static String _mintReconnectToken() {
|
||||||
|
final random = Random.secure();
|
||||||
|
final bytes = List<int>.generate(32, (_) => random.nextInt(256), growable: false);
|
||||||
|
return base64Url.encode(bytes).replaceAll('=', '');
|
||||||
|
}
|
||||||
|
|
||||||
/// Connect to the relay WebSocket and set up the message listener.
|
/// Connect to the relay WebSocket and set up the message listener.
|
||||||
/// Returns a completer that completes when the expected response arrives.
|
/// Returns a completer that completes when the expected response arrives.
|
||||||
Future<WebSocketChannel> _connectToRelay() async {
|
Future<WebSocketChannel> _connectToRelay({Duration? timeout, String operation = 'WatchTogether connect'}) async {
|
||||||
final uri = Uri.parse(_relayUrl);
|
final channel = _channelFactory(endpoint.webSocketUri);
|
||||||
final channel = WebSocketChannel.connect(uri);
|
|
||||||
|
|
||||||
// Wait for the connection to be established
|
try {
|
||||||
await channel.ready;
|
final ready = channel.ready;
|
||||||
|
if (timeout == null) {
|
||||||
return channel;
|
await ready;
|
||||||
|
} else {
|
||||||
|
await ready.namedTimeout(timeout, operation: operation);
|
||||||
|
}
|
||||||
|
return channel;
|
||||||
|
} catch (_) {
|
||||||
|
try {
|
||||||
|
unawaited(channel.sink.close());
|
||||||
|
} catch (error) {
|
||||||
|
appLogger.d('WatchTogether: pending channel close ignored', error: error);
|
||||||
|
}
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Connect, listen, and send a room setup announcement.
|
/// Connect, listen, and send a room setup announcement.
|
||||||
Future<Completer<void>> _connectAndAnnounce(String type, int epoch) async {
|
Future<Completer<void>> _connectAndAnnounce(
|
||||||
final channel = await _connectToRelay();
|
String type,
|
||||||
|
int epoch, {
|
||||||
|
Duration? connectTimeout,
|
||||||
|
String connectOperation = 'WatchTogether connect',
|
||||||
|
}) async {
|
||||||
|
final channel = await _connectToRelay(timeout: connectTimeout, operation: connectOperation);
|
||||||
if (_disposed || epoch != _connectionEpoch || _sessionId == null) {
|
if (_disposed || epoch != _connectionEpoch || _sessionId == null) {
|
||||||
unawaited(channel.sink.close());
|
unawaited(channel.sink.close());
|
||||||
throw StateError('Watch Together connection attempt became stale');
|
throw StateError('Watch Together connection attempt became stale');
|
||||||
@@ -145,7 +183,15 @@ class WatchTogetherPeerService with KeepaliveMixin {
|
|||||||
Completer<void> _announce(String type) {
|
Completer<void> _announce(String type) {
|
||||||
final completer = Completer<void>();
|
final completer = Completer<void>();
|
||||||
_setupCompleter = completer;
|
_setupCompleter = completer;
|
||||||
_sendRaw({'type': type, 'sessionId': _sessionId, 'peerId': _myPeerId});
|
_setupRequestType = type;
|
||||||
|
final reconnectToken = _reconnectToken;
|
||||||
|
_sendRaw({
|
||||||
|
'type': type,
|
||||||
|
'sessionId': _sessionId,
|
||||||
|
'peerId': _myPeerId,
|
||||||
|
'reconnectToken': ?reconnectToken,
|
||||||
|
'protocolVersion': _relayProtocolVersion,
|
||||||
|
});
|
||||||
return completer;
|
return completer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,6 +214,7 @@ class WatchTogetherPeerService with KeepaliveMixin {
|
|||||||
if (_setupCompleter case final completer? when !completer.isCompleted) {
|
if (_setupCompleter case final completer? when !completer.isCompleted) {
|
||||||
completer.completeError(error);
|
completer.completeError(error);
|
||||||
_setupCompleter = null;
|
_setupCompleter = null;
|
||||||
|
_setupRequestType = null;
|
||||||
}
|
}
|
||||||
_handleWebSocketClosed();
|
_handleWebSocketClosed();
|
||||||
},
|
},
|
||||||
@@ -179,12 +226,135 @@ class WatchTogetherPeerService with KeepaliveMixin {
|
|||||||
const PeerError(type: PeerErrorType.connectionFailed, message: 'WebSocket closed before setup completed'),
|
const PeerError(type: PeerErrorType.connectionFailed, message: 'WebSocket closed before setup completed'),
|
||||||
);
|
);
|
||||||
_setupCompleter = null;
|
_setupCompleter = null;
|
||||||
|
_setupRequestType = null;
|
||||||
}
|
}
|
||||||
_handleWebSocketClosed();
|
_handleWebSocketClosed();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static final RegExp _reconnectTokenPattern = RegExp(r'^[A-Za-z0-9_-]{43}$');
|
||||||
|
|
||||||
|
PeerError _invalidSetupResponse(String type) {
|
||||||
|
return PeerError(type: PeerErrorType.serverError, message: 'Relay returned an invalid $type response');
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> _acceptSetupResponse(Map<String, dynamic> msg, String type) {
|
||||||
|
final responseSessionId = msg['sessionId'];
|
||||||
|
final hostPeerId = msg['hostPeerId'];
|
||||||
|
final reconnectToken = msg['reconnectToken'];
|
||||||
|
final protocolVersion = msg['protocolVersion'];
|
||||||
|
if (responseSessionId is! String ||
|
||||||
|
responseSessionId != _sessionId ||
|
||||||
|
hostPeerId is! String ||
|
||||||
|
!RelayProtocol.isValidPeerId(hostPeerId) ||
|
||||||
|
(_isHost && hostPeerId != _myPeerId) ||
|
||||||
|
reconnectToken is! String ||
|
||||||
|
reconnectToken != _reconnectToken ||
|
||||||
|
!_reconnectTokenPattern.hasMatch(reconnectToken) ||
|
||||||
|
protocolVersion != _relayProtocolVersion) {
|
||||||
|
throw _invalidSetupResponse(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
final establishedHostPeerId = _hostPeerId;
|
||||||
|
if (!_isHost && _reconnectToken != null && establishedHostPeerId != null && hostPeerId != establishedHostPeerId) {
|
||||||
|
throw const _PinnedHostChangedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
final rawPeers = msg['peers'];
|
||||||
|
final peers = <String>[];
|
||||||
|
if (rawPeers != null) {
|
||||||
|
if (rawPeers is! List) throw _invalidSetupResponse(type);
|
||||||
|
for (final peerId in rawPeers) {
|
||||||
|
if (peerId is! String || !RelayProtocol.isValidPeerId(peerId)) {
|
||||||
|
throw _invalidSetupResponse(type);
|
||||||
|
}
|
||||||
|
peers.add(peerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_hostPeerId = hostPeerId;
|
||||||
|
_reconnectToken = reconnectToken;
|
||||||
|
return peers;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _acceptTeardownResponse(Map<String, dynamic> msg, String type) {
|
||||||
|
if (msg['sessionId'] != _sessionId || msg['protocolVersion'] != _relayProtocolVersion) {
|
||||||
|
throw _invalidSetupResponse(type);
|
||||||
|
}
|
||||||
|
if (type == RelayProtocol.left && msg['peerId'] != _myPeerId) {
|
||||||
|
throw _invalidSetupResponse(type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _failSetup(PeerError error) {
|
||||||
|
_safeAdd(_errorController, error);
|
||||||
|
if (_setupCompleter case final completer? when !completer.isCompleted) {
|
||||||
|
_setupCompleter = null;
|
||||||
|
_setupRequestType = null;
|
||||||
|
completer.completeError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _rejectAdmittedGuestSetup(_PinnedHostChangedError error) {
|
||||||
|
_safeAdd(_errorController, error);
|
||||||
|
final rejectedSetup = _setupCompleter;
|
||||||
|
if (rejectedSetup == null || rejectedSetup.isCompleted) return;
|
||||||
|
|
||||||
|
final leaveCompleter = Completer<void>();
|
||||||
|
_setupCompleter = leaveCompleter;
|
||||||
|
_setupRequestType = RelayProtocol.leave;
|
||||||
|
_sendRaw({
|
||||||
|
'type': RelayProtocol.leave,
|
||||||
|
'sessionId': _sessionId,
|
||||||
|
'peerId': _myPeerId,
|
||||||
|
'reconnectToken': _reconnectToken,
|
||||||
|
'protocolVersion': _relayProtocolVersion,
|
||||||
|
});
|
||||||
|
unawaited(() async {
|
||||||
|
try {
|
||||||
|
await leaveCompleter.future.namedTimeout(
|
||||||
|
const Duration(seconds: 10),
|
||||||
|
operation: 'WatchTogether rejected reconnect leave',
|
||||||
|
);
|
||||||
|
} catch (releaseError) {
|
||||||
|
appLogger.d('WatchTogether: rejected reconnect leave ignored', error: releaseError);
|
||||||
|
} finally {
|
||||||
|
if (identical(_setupCompleter, leaveCompleter)) {
|
||||||
|
_setupCompleter = null;
|
||||||
|
_setupRequestType = null;
|
||||||
|
}
|
||||||
|
if (!rejectedSetup.isCompleted) rejectedSetup.completeError(error);
|
||||||
|
}
|
||||||
|
}());
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isExhaustedGuestReconnectRoomNotFound(String code) =>
|
||||||
|
code == RelayProtocol.roomNotFoundCode &&
|
||||||
|
!_isHost &&
|
||||||
|
!_initialSetupInProgress &&
|
||||||
|
!_teardownInProgress &&
|
||||||
|
_hostPeerId != null &&
|
||||||
|
_setupRequestType == RelayProtocol.join &&
|
||||||
|
_reconnectAttempts >= _maxReconnectAttempts;
|
||||||
|
|
||||||
|
void _handleGuestSessionEnded() {
|
||||||
|
_teardownInProgress = true;
|
||||||
|
++_connectionEpoch;
|
||||||
|
_reconnectTimer?.cancel();
|
||||||
|
_reconnectTimer = null;
|
||||||
|
stopKeepalive();
|
||||||
|
final error = const PeerError(type: PeerErrorType.invalidSession, message: 'Watch Together session ended');
|
||||||
|
if (_setupCompleter case final completer? when !completer.isCompleted) {
|
||||||
|
_setupCompleter = null;
|
||||||
|
_setupRequestType = null;
|
||||||
|
_safeAdd(_errorController, error);
|
||||||
|
completer.completeError(error);
|
||||||
|
}
|
||||||
|
_safeAdd(_sessionEndedController, null);
|
||||||
|
_handleWebSocketClosed();
|
||||||
|
}
|
||||||
|
|
||||||
/// Handle an incoming server message (JSON string).
|
/// Handle an incoming server message (JSON string).
|
||||||
void _handleServerMessage(String raw) {
|
void _handleServerMessage(String raw) {
|
||||||
try {
|
try {
|
||||||
@@ -193,15 +363,40 @@ class WatchTogetherPeerService with KeepaliveMixin {
|
|||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case RelayProtocol.created:
|
case RelayProtocol.created:
|
||||||
appLogger.d('WatchTogether: Room created: ${msg['sessionId']}');
|
late final List<String> peers;
|
||||||
|
try {
|
||||||
|
peers = _acceptSetupResponse(msg, RelayProtocol.created);
|
||||||
|
} on _PinnedHostChangedError catch (error) {
|
||||||
|
_rejectAdmittedGuestSetup(error);
|
||||||
|
break;
|
||||||
|
} on PeerError catch (error) {
|
||||||
|
_failSetup(error);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
appLogger.d('WatchTogether: Room created: ${msg['sessionId']} with peers: $peers');
|
||||||
|
for (final peerId in peers) {
|
||||||
|
if (_connectedPeers.add(peerId)) {
|
||||||
|
_safeAdd(_peerConnectedController, peerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
_safeAdd(_connectionStateController, true);
|
_safeAdd(_connectionStateController, true);
|
||||||
if (_setupCompleter case final completer? when !completer.isCompleted) {
|
if (_setupCompleter case final completer? when !completer.isCompleted) {
|
||||||
completer.complete();
|
|
||||||
_setupCompleter = null;
|
_setupCompleter = null;
|
||||||
|
_setupRequestType = null;
|
||||||
|
completer.complete();
|
||||||
}
|
}
|
||||||
|
|
||||||
case RelayProtocol.joined:
|
case RelayProtocol.joined:
|
||||||
final peers = (msg['peers'] as List<dynamic>?)?.cast<String>() ?? [];
|
late final List<String> peers;
|
||||||
|
try {
|
||||||
|
peers = _acceptSetupResponse(msg, RelayProtocol.joined);
|
||||||
|
} on _PinnedHostChangedError catch (error) {
|
||||||
|
_rejectAdmittedGuestSetup(error);
|
||||||
|
break;
|
||||||
|
} on PeerError catch (error) {
|
||||||
|
_failSetup(error);
|
||||||
|
break;
|
||||||
|
}
|
||||||
appLogger.d('WatchTogether: Joined room ${msg['sessionId']} with peers: $peers');
|
appLogger.d('WatchTogether: Joined room ${msg['sessionId']} with peers: $peers');
|
||||||
for (final peerId in peers) {
|
for (final peerId in peers) {
|
||||||
_connectedPeers.add(peerId);
|
_connectedPeers.add(peerId);
|
||||||
@@ -209,8 +404,9 @@ class WatchTogetherPeerService with KeepaliveMixin {
|
|||||||
}
|
}
|
||||||
_safeAdd(_connectionStateController, true);
|
_safeAdd(_connectionStateController, true);
|
||||||
if (_setupCompleter case final completer? when !completer.isCompleted) {
|
if (_setupCompleter case final completer? when !completer.isCompleted) {
|
||||||
completer.complete();
|
|
||||||
_setupCompleter = null;
|
_setupCompleter = null;
|
||||||
|
_setupRequestType = null;
|
||||||
|
completer.complete();
|
||||||
}
|
}
|
||||||
|
|
||||||
case RelayProtocol.peerJoined:
|
case RelayProtocol.peerJoined:
|
||||||
@@ -247,15 +443,55 @@ class WatchTogetherPeerService with KeepaliveMixin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case RelayProtocol.left:
|
||||||
|
try {
|
||||||
|
_acceptTeardownResponse(msg, RelayProtocol.left);
|
||||||
|
} on PeerError catch (error) {
|
||||||
|
_failSetup(error);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (_setupCompleter case final completer? when !completer.isCompleted) {
|
||||||
|
_setupCompleter = null;
|
||||||
|
_setupRequestType = null;
|
||||||
|
completer.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
case RelayProtocol.ended:
|
||||||
|
try {
|
||||||
|
_acceptTeardownResponse(msg, RelayProtocol.ended);
|
||||||
|
} on PeerError catch (error) {
|
||||||
|
_failSetup(error);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
final expectedTeardown =
|
||||||
|
_setupRequestType == RelayProtocol.endSession || _setupRequestType == RelayProtocol.leave;
|
||||||
|
if (expectedTeardown) {
|
||||||
|
if (_setupCompleter case final completer? when !completer.isCompleted) {
|
||||||
|
_setupCompleter = null;
|
||||||
|
_setupRequestType = null;
|
||||||
|
completer.complete();
|
||||||
|
}
|
||||||
|
} else if (!_isHost) {
|
||||||
|
_handleGuestSessionEnded();
|
||||||
|
} else {
|
||||||
|
_failSetup(_invalidSetupResponse(RelayProtocol.ended));
|
||||||
|
}
|
||||||
|
|
||||||
case RelayProtocol.error:
|
case RelayProtocol.error:
|
||||||
final code = msg['code'] as String? ?? 'unknown';
|
final code = msg['code'] as String? ?? 'unknown';
|
||||||
final message = msg['message'] as String? ?? t.common.unknown;
|
final message = msg['message'] as String? ?? t.common.unknown;
|
||||||
|
if (_isExhaustedGuestReconnectRoomNotFound(code)) {
|
||||||
|
appLogger.d('WatchTogether: Room gone after reconnect retries; guest session ended');
|
||||||
|
_handleGuestSessionEnded();
|
||||||
|
break;
|
||||||
|
}
|
||||||
appLogger.e('WatchTogether: Server error: $code - $message');
|
appLogger.e('WatchTogether: Server error: $code - $message');
|
||||||
final error = PeerError(type: PeerErrorType.serverError, message: '$code: $message', serverCode: code);
|
final error = PeerError(type: PeerErrorType.serverError, message: '$code: $message', serverCode: code);
|
||||||
_safeAdd(_errorController, error);
|
_safeAdd(_errorController, error);
|
||||||
if (_setupCompleter case final completer? when !completer.isCompleted) {
|
if (_setupCompleter case final completer? when !completer.isCompleted) {
|
||||||
completer.completeError(error);
|
|
||||||
_setupCompleter = null;
|
_setupCompleter = null;
|
||||||
|
_setupRequestType = null;
|
||||||
|
completer.completeError(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
case RelayProtocol.pong:
|
case RelayProtocol.pong:
|
||||||
@@ -265,8 +501,13 @@ class WatchTogetherPeerService with KeepaliveMixin {
|
|||||||
default:
|
default:
|
||||||
appLogger.w('WatchTogether: Unknown server message type: $type');
|
appLogger.w('WatchTogether: Unknown server message type: $type');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (_) {
|
||||||
appLogger.e('WatchTogether: Failed to parse server message', error: e);
|
appLogger.e('WatchTogether: Failed to parse server message');
|
||||||
|
if (_setupCompleter case final completer? when !completer.isCompleted) {
|
||||||
|
_failSetup(
|
||||||
|
const PeerError(type: PeerErrorType.serverError, message: 'Relay returned an invalid setup response'),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,7 +536,8 @@ class WatchTogetherPeerService with KeepaliveMixin {
|
|||||||
/// Handle the WebSocket being closed unexpectedly — attempt reconnection.
|
/// Handle the WebSocket being closed unexpectedly — attempt reconnection.
|
||||||
void _handleWebSocketClosed() {
|
void _handleWebSocketClosed() {
|
||||||
final channel = _channel;
|
final channel = _channel;
|
||||||
++_connectionEpoch;
|
final shouldReconnect = !_initialSetupInProgress && !_teardownInProgress;
|
||||||
|
if (shouldReconnect) ++_connectionEpoch;
|
||||||
stopKeepalive();
|
stopKeepalive();
|
||||||
unawaited(_channelSubscription?.cancel());
|
unawaited(_channelSubscription?.cancel());
|
||||||
_channelSubscription = null;
|
_channelSubscription = null;
|
||||||
@@ -308,7 +550,7 @@ class WatchTogetherPeerService with KeepaliveMixin {
|
|||||||
_connectedPeers.clear();
|
_connectedPeers.clear();
|
||||||
_safeAdd(_connectionStateController, false);
|
_safeAdd(_connectionStateController, false);
|
||||||
|
|
||||||
if (!_disposed && _sessionId != null) {
|
if (shouldReconnect && !_disposed && _sessionId != null) {
|
||||||
_attemptReconnect(_connectionEpoch);
|
_attemptReconnect(_connectionEpoch);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -355,6 +597,8 @@ class WatchTogetherPeerService with KeepaliveMixin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await debugReconnectSetupSucceededBarrier?.call();
|
||||||
|
|
||||||
if (_disposed || epoch != _connectionEpoch) return;
|
if (_disposed || epoch != _connectionEpoch) return;
|
||||||
_reconnectAttempts = 0;
|
_reconnectAttempts = 0;
|
||||||
appLogger.d('WatchTogether: Reconnected successfully');
|
appLogger.d('WatchTogether: Reconnected successfully');
|
||||||
@@ -371,12 +615,67 @@ class WatchTogetherPeerService with KeepaliveMixin {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _isRetryableInitialSetupError(Object error) =>
|
||||||
|
error is TimeoutException ||
|
||||||
|
(error is PeerError &&
|
||||||
|
(error.type == PeerErrorType.connectionFailed ||
|
||||||
|
error.type == PeerErrorType.networkError ||
|
||||||
|
error.type == PeerErrorType.timeout)) ||
|
||||||
|
error is! PeerError;
|
||||||
|
|
||||||
|
Future<void> _resetTransportForInitialRetry() async {
|
||||||
|
stopKeepalive();
|
||||||
|
final subscription = _channelSubscription;
|
||||||
|
final channel = _channel;
|
||||||
|
_channelSubscription = null;
|
||||||
|
_channel = null;
|
||||||
|
_setupCompleter = null;
|
||||||
|
_setupRequestType = null;
|
||||||
|
await subscription?.cancel();
|
||||||
|
try {
|
||||||
|
await channel?.sink.close();
|
||||||
|
} catch (error) {
|
||||||
|
appLogger.d('WatchTogether: setup retry close ignored', error: error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _performInitialSetup(String type, int epoch, PeerError timeoutError) async {
|
||||||
|
_initialSetupInProgress = true;
|
||||||
|
try {
|
||||||
|
for (var attempt = 0; attempt < _maxReconnectAttempts; attempt++) {
|
||||||
|
try {
|
||||||
|
final completer = await _connectAndAnnounce(type, epoch);
|
||||||
|
await completer.future.timeout(const Duration(seconds: 10), onTimeout: () => throw timeoutError);
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
if (_disposed || epoch != _connectionEpoch) rethrow;
|
||||||
|
await _resetTransportForInitialRetry();
|
||||||
|
if (!_isRetryableInitialSetupError(error) || attempt + 1 >= _maxReconnectAttempts) {
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
await Future<void>.delayed(Duration(milliseconds: 250 * (attempt + 1)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_initialSetupInProgress = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _bestEffortReleaseFailedSetup(Object setupError) async {
|
||||||
|
if (!_isRetryableInitialSetupError(setupError)) return;
|
||||||
|
try {
|
||||||
|
await releaseSession();
|
||||||
|
} catch (releaseError) {
|
||||||
|
appLogger.d('WatchTogether: failed setup reservation release ignored', error: releaseError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Create a new session as host
|
/// Create a new session as host
|
||||||
///
|
///
|
||||||
/// Returns the session ID that others can use to join.
|
/// Returns the session ID that others can use to join.
|
||||||
/// If [sessionId] is provided, uses that instead of generating a new one.
|
/// If [sessionId] is provided, uses that instead of generating a new one.
|
||||||
Future<String> createSession({String? sessionId}) async {
|
Future<String> createSession({String? sessionId}) async {
|
||||||
if (_channel != null) {
|
if (_sessionId != null || _channel != null) {
|
||||||
await disconnect();
|
await disconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,24 +689,23 @@ class WatchTogetherPeerService with KeepaliveMixin {
|
|||||||
}
|
}
|
||||||
_isHost = true;
|
_isHost = true;
|
||||||
_sessionId = resolvedSessionId;
|
_sessionId = resolvedSessionId;
|
||||||
_myPeerId = watchTogetherHostPeerId(resolvedSessionId);
|
_myPeerId = const Uuid().v4();
|
||||||
|
_reconnectToken = _mintReconnectToken();
|
||||||
_reconnectAttempts = 0;
|
_reconnectAttempts = 0;
|
||||||
final epoch = ++_connectionEpoch;
|
final epoch = ++_connectionEpoch;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final completer = await _connectAndAnnounce(RelayProtocol.create, epoch);
|
await _performInitialSetup(
|
||||||
|
RelayProtocol.create,
|
||||||
await completer.future.timeout(
|
epoch,
|
||||||
const Duration(seconds: 10),
|
const PeerError(type: PeerErrorType.timeout, message: 'Timed out creating session'),
|
||||||
onTimeout: () {
|
|
||||||
throw const PeerError(type: PeerErrorType.timeout, message: 'Timed out creating session');
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
appLogger.d('WatchTogether: Session created: $_sessionId');
|
appLogger.d('WatchTogether: Session created: $_sessionId');
|
||||||
return _sessionId!;
|
return _sessionId!;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
appLogger.e('WatchTogether: Failed to create session', error: e);
|
appLogger.e('WatchTogether: Failed to create session', error: e);
|
||||||
|
await _bestEffortReleaseFailedSetup(e);
|
||||||
await disconnect();
|
await disconnect();
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
@@ -415,7 +713,7 @@ class WatchTogetherPeerService with KeepaliveMixin {
|
|||||||
|
|
||||||
/// Join an existing session as guest.
|
/// Join an existing session as guest.
|
||||||
Future<void> joinSession(String sessionId) async {
|
Future<void> joinSession(String sessionId) async {
|
||||||
if (_channel != null) {
|
if (_sessionId != null || _channel != null) {
|
||||||
await disconnect();
|
await disconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -430,22 +728,21 @@ class WatchTogetherPeerService with KeepaliveMixin {
|
|||||||
_isHost = false;
|
_isHost = false;
|
||||||
_sessionId = resolvedSessionId;
|
_sessionId = resolvedSessionId;
|
||||||
_myPeerId = const Uuid().v4();
|
_myPeerId = const Uuid().v4();
|
||||||
|
_reconnectToken = _mintReconnectToken();
|
||||||
_reconnectAttempts = 0;
|
_reconnectAttempts = 0;
|
||||||
final epoch = ++_connectionEpoch;
|
final epoch = ++_connectionEpoch;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final completer = await _connectAndAnnounce(RelayProtocol.join, epoch);
|
await _performInitialSetup(
|
||||||
|
RelayProtocol.join,
|
||||||
await completer.future.timeout(
|
epoch,
|
||||||
const Duration(seconds: 10),
|
PeerError(type: PeerErrorType.timeout, message: t.watchTogether.failedToJoin),
|
||||||
onTimeout: () {
|
|
||||||
throw PeerError(type: PeerErrorType.timeout, message: t.watchTogether.failedToJoin);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
appLogger.d('WatchTogether: Joined session: $_sessionId');
|
appLogger.d('WatchTogether: Joined session: $_sessionId');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
appLogger.e('WatchTogether: Failed to join session', error: e);
|
appLogger.e('WatchTogether: Failed to join session', error: e);
|
||||||
|
await _bestEffortReleaseFailedSetup(e);
|
||||||
await disconnect();
|
await disconnect();
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
@@ -466,7 +763,82 @@ class WatchTogetherPeerService with KeepaliveMixin {
|
|||||||
_sendRaw({'type': RelayProtocol.sendTo, 'to': peerId, 'payload': payload});
|
_sendRaw({'type': RelayProtocol.sendTo, 'to': peerId, 'payload': payload});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Disconnect from all peers and close the session
|
/// Explicitly release this peer's relay ownership. Hosts destroy the room;
|
||||||
|
/// guests release their reserved reconnect identity. If transport was lost,
|
||||||
|
/// authenticate a fresh connection first so an intentional exit is not
|
||||||
|
/// mistaken for a transient disconnect.
|
||||||
|
Future<void> releaseSession() {
|
||||||
|
final active = _releaseFuture;
|
||||||
|
if (active != null) return active;
|
||||||
|
final operation = _releaseSession();
|
||||||
|
_releaseFuture = operation;
|
||||||
|
return operation.whenComplete(() {
|
||||||
|
if (identical(_releaseFuture, operation)) _releaseFuture = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _releaseSession() async {
|
||||||
|
if (_sessionId == null || _myPeerId == null || _reconnectToken == null) return;
|
||||||
|
|
||||||
|
_teardownInProgress = true;
|
||||||
|
_reconnectTimer?.cancel();
|
||||||
|
_reconnectTimer = null;
|
||||||
|
final epoch = ++_connectionEpoch;
|
||||||
|
try {
|
||||||
|
if (_setupCompleter case final completer? when !completer.isCompleted) {
|
||||||
|
await _resetTransportForInitialRetry();
|
||||||
|
}
|
||||||
|
for (var attempt = 0; attempt < _maxReconnectAttempts; attempt++) {
|
||||||
|
try {
|
||||||
|
if (_channel == null) {
|
||||||
|
final reconnectCompleter = await _connectAndAnnounce(
|
||||||
|
RelayProtocol.join,
|
||||||
|
epoch,
|
||||||
|
connectTimeout: const Duration(seconds: 10),
|
||||||
|
connectOperation: 'WatchTogether release reconnect',
|
||||||
|
);
|
||||||
|
await reconnectCompleter.future.namedTimeout(
|
||||||
|
const Duration(seconds: 10),
|
||||||
|
operation: 'WatchTogether release reconnect',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final releaseCompleter = Completer<void>();
|
||||||
|
_setupCompleter = releaseCompleter;
|
||||||
|
_setupRequestType = _isHost ? RelayProtocol.endSession : RelayProtocol.leave;
|
||||||
|
_sendRaw({
|
||||||
|
'type': _isHost ? RelayProtocol.endSession : RelayProtocol.leave,
|
||||||
|
'sessionId': _sessionId,
|
||||||
|
'peerId': _myPeerId,
|
||||||
|
'reconnectToken': _reconnectToken,
|
||||||
|
'protocolVersion': _relayProtocolVersion,
|
||||||
|
});
|
||||||
|
await releaseCompleter.future.namedTimeout(
|
||||||
|
const Duration(seconds: 10),
|
||||||
|
operation: _isHost ? 'WatchTogether end session' : 'WatchTogether leave session',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
if (error is PeerError &&
|
||||||
|
(error.serverCode == RelayProtocol.roomNotFoundCode ||
|
||||||
|
error.serverCode == RelayProtocol.notInRoomCode ||
|
||||||
|
(!_isHost && error.serverCode == RelayProtocol.peerIdUnavailableCode))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await _resetTransportForInitialRetry();
|
||||||
|
if (!_isRetryableInitialSetupError(error) || attempt + 1 >= _maxReconnectAttempts) {
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
await Future<void>.delayed(Duration(milliseconds: 250 * (attempt + 1)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_teardownInProgress = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close and forget local relay state without sending a release. Established
|
||||||
|
/// intentional exits call [releaseSession] before this cleanup step.
|
||||||
Future<void> disconnect() async {
|
Future<void> disconnect() async {
|
||||||
appLogger.d('WatchTogether: Disconnecting...');
|
appLogger.d('WatchTogether: Disconnecting...');
|
||||||
++_connectionEpoch;
|
++_connectionEpoch;
|
||||||
@@ -480,14 +852,18 @@ class WatchTogetherPeerService with KeepaliveMixin {
|
|||||||
_channel = null;
|
_channel = null;
|
||||||
final setupCompleter = _setupCompleter;
|
final setupCompleter = _setupCompleter;
|
||||||
_setupCompleter = null;
|
_setupCompleter = null;
|
||||||
|
_setupRequestType = null;
|
||||||
if (setupCompleter != null && !setupCompleter.isCompleted) {
|
if (setupCompleter != null && !setupCompleter.isCompleted) {
|
||||||
setupCompleter.completeError(StateError('Watch Together connection cancelled'));
|
setupCompleter.completeError(StateError('Watch Together connection cancelled'));
|
||||||
}
|
}
|
||||||
_connectedPeers.clear();
|
_connectedPeers.clear();
|
||||||
_sessionId = null;
|
_sessionId = null;
|
||||||
_myPeerId = null;
|
_myPeerId = null;
|
||||||
|
_reconnectToken = null;
|
||||||
|
_hostPeerId = null;
|
||||||
_isHost = false;
|
_isHost = false;
|
||||||
_reconnectAttempts = 0;
|
_reconnectAttempts = 0;
|
||||||
|
_teardownInProgress = false;
|
||||||
|
|
||||||
unawaited(subscription?.cancel());
|
unawaited(subscription?.cancel());
|
||||||
try {
|
try {
|
||||||
@@ -509,5 +885,6 @@ class WatchTogetherPeerService with KeepaliveMixin {
|
|||||||
_messageReceivedController.close();
|
_messageReceivedController.close();
|
||||||
_errorController.close();
|
_errorController.close();
|
||||||
_connectionStateController.close();
|
_connectionStateController.close();
|
||||||
|
_sessionEndedController.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/// Canonical HTTP(S) base endpoint for a Watch Together relay.
|
||||||
|
///
|
||||||
|
/// A base may include a reverse-proxy path prefix. The concrete health and
|
||||||
|
/// WebSocket routes are always appended as path segments.
|
||||||
|
final class WatchTogetherRelayEndpoint {
|
||||||
|
WatchTogetherRelayEndpoint._(this._baseUri);
|
||||||
|
|
||||||
|
static const String defaultBaseUrl = 'https://ice.plezy.app';
|
||||||
|
|
||||||
|
static final WatchTogetherRelayEndpoint defaultEndpoint = WatchTogetherRelayEndpoint._(Uri.parse(defaultBaseUrl));
|
||||||
|
|
||||||
|
final Uri _baseUri;
|
||||||
|
|
||||||
|
String get canonicalBaseUrl => _baseUri.toString();
|
||||||
|
|
||||||
|
Uri get healthUri => _appendPathSegment('health');
|
||||||
|
|
||||||
|
Uri get webSocketUri => _appendPathSegment('relay').replace(scheme: _baseUri.scheme == 'https' ? 'wss' : 'ws');
|
||||||
|
|
||||||
|
static WatchTogetherRelayEndpoint resolve(String? value) {
|
||||||
|
if (value == null || value.trim().isEmpty) return defaultEndpoint;
|
||||||
|
final endpoint = tryParseCustom(value);
|
||||||
|
if (endpoint == null) {
|
||||||
|
throw FormatException('Invalid Watch Together relay base URL');
|
||||||
|
}
|
||||||
|
return endpoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
static WatchTogetherRelayEndpoint? tryParseCustom(String value) {
|
||||||
|
final trimmed = value.trim();
|
||||||
|
if (trimmed.isEmpty) return null;
|
||||||
|
|
||||||
|
final Uri uri;
|
||||||
|
try {
|
||||||
|
uri = Uri.parse(trimmed);
|
||||||
|
if (uri.hasPort && (uri.port < 1 || uri.port > 65535)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} on FormatException {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((uri.scheme != 'http' && uri.scheme != 'https') ||
|
||||||
|
!uri.isAbsolute ||
|
||||||
|
!uri.hasAuthority ||
|
||||||
|
uri.host.isEmpty ||
|
||||||
|
uri.userInfo.isNotEmpty ||
|
||||||
|
uri.hasQuery ||
|
||||||
|
uri.hasFragment) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final normalized = uri.normalizePath();
|
||||||
|
var path = normalized.path;
|
||||||
|
while (path.endsWith('/')) {
|
||||||
|
path = path.substring(0, path.length - 1);
|
||||||
|
}
|
||||||
|
final hasDefaultPort =
|
||||||
|
normalized.hasPort &&
|
||||||
|
((normalized.scheme == 'http' && normalized.port == 80) ||
|
||||||
|
(normalized.scheme == 'https' && normalized.port == 443));
|
||||||
|
final canonical = Uri(
|
||||||
|
scheme: normalized.scheme,
|
||||||
|
host: normalized.host,
|
||||||
|
port: normalized.hasPort && !hasDefaultPort ? normalized.port : null,
|
||||||
|
path: path,
|
||||||
|
);
|
||||||
|
return WatchTogetherRelayEndpoint._(canonical);
|
||||||
|
}
|
||||||
|
|
||||||
|
Uri _appendPathSegment(String segment) {
|
||||||
|
final prefix = _baseUri.path;
|
||||||
|
return _baseUri.replace(path: '$prefix/$segment');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) || other is WatchTogetherRelayEndpoint && other.canonicalBaseUrl == canonicalBaseUrl;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => canonicalBaseUrl.hashCode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => canonicalBaseUrl;
|
||||||
|
}
|
||||||
@@ -273,7 +273,11 @@ class _SessionMenuSheet extends StatelessWidget {
|
|||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
OverlaySheetController.closeAdaptive(context);
|
OverlaySheetController.closeAdaptive(context);
|
||||||
}
|
}
|
||||||
unawaited(provider.leaveSession());
|
unawaited(
|
||||||
|
provider.leaveSession().catchError((Object error, StackTrace stackTrace) {
|
||||||
|
appLogger.e('WatchTogether: Overlay leave failed', error: error, stackTrace: stackTrace);
|
||||||
|
}),
|
||||||
|
);
|
||||||
onLeaveSession?.call();
|
onLeaveSession?.call();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-3
@@ -1,10 +1,14 @@
|
|||||||
{
|
{
|
||||||
|
"protocolVersion": 2,
|
||||||
|
"legacyProtocolVersion": 0,
|
||||||
"clientMessageTypes": {
|
"clientMessageTypes": {
|
||||||
"create": "create",
|
"create": "create",
|
||||||
"join": "join",
|
"join": "join",
|
||||||
"broadcast": "broadcast",
|
"broadcast": "broadcast",
|
||||||
"sendTo": "sendTo",
|
"sendTo": "sendTo",
|
||||||
"ping": "ping"
|
"ping": "ping",
|
||||||
|
"leave": "leave",
|
||||||
|
"endSession": "endSession"
|
||||||
},
|
},
|
||||||
"serverMessageTypes": {
|
"serverMessageTypes": {
|
||||||
"created": "created",
|
"created": "created",
|
||||||
@@ -13,7 +17,9 @@
|
|||||||
"peerLeft": "peerLeft",
|
"peerLeft": "peerLeft",
|
||||||
"message": "message",
|
"message": "message",
|
||||||
"error": "error",
|
"error": "error",
|
||||||
"pong": "pong"
|
"pong": "pong",
|
||||||
|
"left": "left",
|
||||||
|
"ended": "ended"
|
||||||
},
|
},
|
||||||
"errorCodes": {
|
"errorCodes": {
|
||||||
"rateLimited": "rate_limited",
|
"rateLimited": "rate_limited",
|
||||||
@@ -22,7 +28,9 @@
|
|||||||
"roomNotFound": "room_not_found",
|
"roomNotFound": "room_not_found",
|
||||||
"roomFull": "room_full",
|
"roomFull": "room_full",
|
||||||
"notInRoom": "not_in_room",
|
"notInRoom": "not_in_room",
|
||||||
"alreadyInRoom": "already_in_room"
|
"alreadyInRoom": "already_in_room",
|
||||||
|
"peerIdUnavailable": "peer_id_unavailable",
|
||||||
|
"protocolMismatch": "protocol_mismatch"
|
||||||
},
|
},
|
||||||
"limits": {
|
"limits": {
|
||||||
"maxRoomSize": 8,
|
"maxRoomSize": 8,
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ if python3 scripts/check_build_workflow.py &&
|
|||||||
python3 scripts/check_workflow_action_pins.py &&
|
python3 scripts/check_workflow_action_pins.py &&
|
||||||
python3 scripts/test_check_workflow_action_pins.py &&
|
python3 scripts/test_check_workflow_action_pins.py &&
|
||||||
python3 scripts/test_check_codegen.py &&
|
python3 scripts/test_check_codegen.py &&
|
||||||
|
python3 scripts/test_generate_relay_protocol.py &&
|
||||||
python3 scripts/test_format_native.py &&
|
python3 scripts/test_format_native.py &&
|
||||||
python3 scripts/check_update_packages_workflow.py &&
|
python3 scripts/check_update_packages_workflow.py &&
|
||||||
python3 scripts/test_pubspec_version.py &&
|
python3 scripts/test_pubspec_version.py &&
|
||||||
|
|||||||
@@ -11,17 +11,41 @@ SPEC_PATH = ROOT / "relay_protocol.json"
|
|||||||
DART_PATH = ROOT / "lib/watch_together/services/relay_protocol.g.dart"
|
DART_PATH = ROOT / "lib/watch_together/services/relay_protocol.g.dart"
|
||||||
GO_PATH = ROOT / "server/relay_protocol_gen.go"
|
GO_PATH = ROOT / "server/relay_protocol_gen.go"
|
||||||
|
|
||||||
|
SUPPORTED_ID_PATTERN = r"^[A-Za-z0-9_-]+$"
|
||||||
|
|
||||||
|
|
||||||
def camel_to_pascal(value: str) -> str:
|
def camel_to_pascal(value: str) -> str:
|
||||||
return value[:1].upper() + value[1:]
|
return value[:1].upper() + value[1:]
|
||||||
|
|
||||||
|
|
||||||
|
def validated_id_pattern(spec: dict) -> str:
|
||||||
|
try:
|
||||||
|
pattern = spec["idPattern"]
|
||||||
|
except KeyError:
|
||||||
|
raise ValueError("idPattern is required") from None
|
||||||
|
if not isinstance(pattern, str):
|
||||||
|
raise ValueError("idPattern must be a string")
|
||||||
|
if pattern != SUPPORTED_ID_PATTERN:
|
||||||
|
raise ValueError(
|
||||||
|
f"unsupported idPattern {pattern!r}; expected {SUPPORTED_ID_PATTERN!r}"
|
||||||
|
)
|
||||||
|
return pattern
|
||||||
|
|
||||||
|
|
||||||
def dart_source(spec: dict) -> str:
|
def dart_source(spec: dict) -> str:
|
||||||
|
id_pattern = validated_id_pattern(spec)
|
||||||
lines = [
|
lines = [
|
||||||
"// Generated by scripts/generate_relay_protocol.py. Do not edit.",
|
"// Generated by scripts/generate_relay_protocol.py. Do not edit.",
|
||||||
"",
|
"",
|
||||||
"abstract final class RelayProtocol {",
|
"abstract final class RelayProtocol {",
|
||||||
]
|
]
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
f" static const int protocolVersion = {spec['protocolVersion']};",
|
||||||
|
f" static const int legacyProtocolVersion = {spec['legacyProtocolVersion']};",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
)
|
||||||
for group in ("clientMessageTypes", "serverMessageTypes"):
|
for group in ("clientMessageTypes", "serverMessageTypes"):
|
||||||
for name, value in spec[group].items():
|
for name, value in spec[group].items():
|
||||||
lines.append(f" static const String {name} = {value!r};")
|
lines.append(f" static const String {name} = {value!r};")
|
||||||
@@ -33,7 +57,7 @@ def dart_source(spec: dict) -> str:
|
|||||||
lines.extend(
|
lines.extend(
|
||||||
[
|
[
|
||||||
"",
|
"",
|
||||||
" static final RegExp _idPattern = RegExp(r'^[A-Za-z0-9_-]+$');",
|
f" static final RegExp _idPattern = RegExp(r{id_pattern!r});",
|
||||||
"",
|
"",
|
||||||
" static bool isValidSessionId(String value) =>",
|
" static bool isValidSessionId(String value) =>",
|
||||||
" value.isNotEmpty && value.length <= maxSessionIdLength && _idPattern.hasMatch(value);",
|
" value.isNotEmpty && value.length <= maxSessionIdLength && _idPattern.hasMatch(value);",
|
||||||
@@ -48,6 +72,7 @@ def dart_source(spec: dict) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def go_source(spec: dict) -> str:
|
def go_source(spec: dict) -> str:
|
||||||
|
validated_id_pattern(spec)
|
||||||
lines = [
|
lines = [
|
||||||
"// Code generated by scripts/generate_relay_protocol.py. DO NOT EDIT.",
|
"// Code generated by scripts/generate_relay_protocol.py. DO NOT EDIT.",
|
||||||
"",
|
"",
|
||||||
@@ -55,6 +80,13 @@ def go_source(spec: dict) -> str:
|
|||||||
"",
|
"",
|
||||||
"const (",
|
"const (",
|
||||||
]
|
]
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
f"\trelayProtocolVersion = {spec['protocolVersion']}",
|
||||||
|
f"\tlegacyRelayProtocolVersion = {spec['legacyProtocolVersion']}",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
)
|
||||||
protocol_constants = []
|
protocol_constants = []
|
||||||
for group in ("clientMessageTypes", "serverMessageTypes"):
|
for group in ("clientMessageTypes", "serverMessageTypes"):
|
||||||
protocol_constants.extend(
|
protocol_constants.extend(
|
||||||
@@ -109,8 +141,10 @@ def go_source(spec: dict) -> str:
|
|||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
spec = json.loads(SPEC_PATH.read_text(encoding="utf-8"))
|
spec = json.loads(SPEC_PATH.read_text(encoding="utf-8"))
|
||||||
DART_PATH.write_text(dart_source(spec), encoding="utf-8")
|
dart_output = dart_source(spec)
|
||||||
GO_PATH.write_text(go_source(spec), encoding="utf-8")
|
go_output = go_source(spec)
|
||||||
|
DART_PATH.write_text(dart_output, encoding="utf-8")
|
||||||
|
GO_PATH.write_text(go_output, encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import copy
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import generate_relay_protocol as generator
|
||||||
|
|
||||||
|
|
||||||
|
class RelayProtocolGeneratorTest(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.spec = json.loads(generator.SPEC_PATH.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
def test_supported_pattern_renders_both_targets(self) -> None:
|
||||||
|
dart_output = generator.dart_source(copy.deepcopy(self.spec))
|
||||||
|
go_output = generator.go_source(copy.deepcopy(self.spec))
|
||||||
|
|
||||||
|
self.assertIn(
|
||||||
|
f"RegExp(r{generator.SUPPORTED_ID_PATTERN!r})",
|
||||||
|
dart_output,
|
||||||
|
)
|
||||||
|
self.assertIn("func validRelayID(value string, maxLength int) bool", go_output)
|
||||||
|
|
||||||
|
def test_changed_pattern_fails_before_writing_either_target(self) -> None:
|
||||||
|
changed_spec = copy.deepcopy(self.spec)
|
||||||
|
changed_spec["idPattern"] = r"^[A-Za-z0-9_.-]+$"
|
||||||
|
|
||||||
|
for renderer in (generator.dart_source, generator.go_source):
|
||||||
|
with self.subTest(renderer=renderer.__name__):
|
||||||
|
with self.assertRaisesRegex(ValueError, "idPattern"):
|
||||||
|
renderer(changed_spec)
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||||
|
root = Path(temporary_directory)
|
||||||
|
spec_path = root / "relay_protocol.json"
|
||||||
|
dart_path = root / "relay_protocol.g.dart"
|
||||||
|
go_path = root / "relay_protocol_gen.go"
|
||||||
|
spec_path.write_text(json.dumps(changed_spec), encoding="utf-8")
|
||||||
|
dart_path.write_text("dart sentinel\n", encoding="utf-8")
|
||||||
|
go_path.write_text("go sentinel\n", encoding="utf-8")
|
||||||
|
|
||||||
|
with (
|
||||||
|
mock.patch.object(generator, "SPEC_PATH", spec_path),
|
||||||
|
mock.patch.object(generator, "DART_PATH", dart_path),
|
||||||
|
mock.patch.object(generator, "GO_PATH", go_path),
|
||||||
|
):
|
||||||
|
with self.assertRaisesRegex(ValueError, "idPattern"):
|
||||||
|
generator.main()
|
||||||
|
|
||||||
|
self.assertEqual(dart_path.read_text(encoding="utf-8"), "dart sentinel\n")
|
||||||
|
self.assertEqual(go_path.read_text(encoding="utf-8"), "go sentinel\n")
|
||||||
|
|
||||||
|
def test_missing_pattern_is_rejected(self) -> None:
|
||||||
|
spec = copy.deepcopy(self.spec)
|
||||||
|
del spec["idPattern"]
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(ValueError, "idPattern is required"):
|
||||||
|
generator.validated_id_pattern(spec)
|
||||||
|
|
||||||
|
def test_non_string_pattern_is_rejected(self) -> None:
|
||||||
|
for value in (None, 42, [generator.SUPPORTED_ID_PATTERN]):
|
||||||
|
with self.subTest(value=value):
|
||||||
|
spec = copy.deepcopy(self.spec)
|
||||||
|
spec["idPattern"] = value
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(ValueError, "idPattern must be a string"):
|
||||||
|
generator.validated_id_pattern(spec)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var errInvalidClientAddress = errors.New("invalid client address")
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxForwardedForBytes = 4 * 1024
|
||||||
|
maxForwardedForHops = 32
|
||||||
|
)
|
||||||
|
|
||||||
|
type clientIPResolver struct {
|
||||||
|
trustedProxies []netip.Prefix
|
||||||
|
}
|
||||||
|
|
||||||
|
func newClientIPResolver(trustedProxies []netip.Prefix) clientIPResolver {
|
||||||
|
return clientIPResolver{trustedProxies: append([]netip.Prefix(nil), trustedProxies...)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseTrustedProxyCIDRs(value string) ([]netip.Prefix, error) {
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(value, ",")
|
||||||
|
prefixes := make([]netip.Prefix, 0, len(parts))
|
||||||
|
for _, part := range parts {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if part == "" {
|
||||||
|
return nil, errInvalidClientAddress
|
||||||
|
}
|
||||||
|
prefix, err := netip.ParsePrefix(part)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errInvalidClientAddress
|
||||||
|
}
|
||||||
|
if prefix.Addr().Is4In6() {
|
||||||
|
if prefix.Bits() < 96 {
|
||||||
|
return nil, errInvalidClientAddress
|
||||||
|
}
|
||||||
|
prefix = netip.PrefixFrom(prefix.Addr().Unmap(), prefix.Bits()-96)
|
||||||
|
}
|
||||||
|
prefixes = append(prefixes, prefix.Masked())
|
||||||
|
}
|
||||||
|
return prefixes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r clientIPResolver) resolve(req *http.Request) (string, error) {
|
||||||
|
peer, err := parseRemoteAddress(req.RemoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
return "", errInvalidClientAddress
|
||||||
|
}
|
||||||
|
peer = peer.Unmap()
|
||||||
|
|
||||||
|
if !r.trusted(peer) {
|
||||||
|
return normalizeClientAddress(peer), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
values := req.Header.Values("X-Forwarded-For")
|
||||||
|
totalBytes := 0
|
||||||
|
hopCount := 0
|
||||||
|
for _, value := range values {
|
||||||
|
totalBytes += len(value)
|
||||||
|
if totalBytes > maxForwardedForBytes {
|
||||||
|
return "", errInvalidClientAddress
|
||||||
|
}
|
||||||
|
hopCount += strings.Count(value, ",") + 1
|
||||||
|
if hopCount > maxForwardedForHops {
|
||||||
|
return "", errInvalidClientAddress
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(values) == 0 {
|
||||||
|
return normalizeClientAddress(peer), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
selected := peer
|
||||||
|
useForwardedHop := true
|
||||||
|
parsedHops := 0
|
||||||
|
for valueIndex := len(values) - 1; valueIndex >= 0; valueIndex-- {
|
||||||
|
value := values[valueIndex]
|
||||||
|
end := len(value)
|
||||||
|
for {
|
||||||
|
separator := strings.LastIndexByte(value[:end], ',')
|
||||||
|
element := strings.TrimSpace(value[separator+1 : end])
|
||||||
|
if element == "" {
|
||||||
|
return "", errInvalidClientAddress
|
||||||
|
}
|
||||||
|
addr, parseErr := netip.ParseAddr(element)
|
||||||
|
if parseErr != nil || addr.Zone() != "" {
|
||||||
|
return "", errInvalidClientAddress
|
||||||
|
}
|
||||||
|
parsedHops++
|
||||||
|
if useForwardedHop {
|
||||||
|
if r.trusted(selected) {
|
||||||
|
selected = addr.Unmap()
|
||||||
|
} else {
|
||||||
|
useForwardedHop = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if separator < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
end = separator
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if parsedHops == 0 {
|
||||||
|
return normalizeClientAddress(peer), nil
|
||||||
|
}
|
||||||
|
return normalizeClientAddress(selected), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r clientIPResolver) trusted(addr netip.Addr) bool {
|
||||||
|
addr = addr.Unmap()
|
||||||
|
for _, prefix := range r.trustedProxies {
|
||||||
|
if prefix.Contains(addr) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseRemoteAddress(remote string) (netip.Addr, error) {
|
||||||
|
host, _, err := net.SplitHostPort(remote)
|
||||||
|
if err == nil {
|
||||||
|
addr, parseErr := netip.ParseAddr(host)
|
||||||
|
if parseErr != nil || addr.Zone() != "" {
|
||||||
|
return netip.Addr{}, errInvalidClientAddress
|
||||||
|
}
|
||||||
|
return addr, nil
|
||||||
|
}
|
||||||
|
addr, parseErr := netip.ParseAddr(remote)
|
||||||
|
if parseErr != nil || addr.Zone() != "" {
|
||||||
|
return netip.Addr{}, errInvalidClientAddress
|
||||||
|
}
|
||||||
|
return addr, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeClientAddress(addr netip.Addr) string {
|
||||||
|
addr = addr.Unmap()
|
||||||
|
if addr.Is6() {
|
||||||
|
return netip.PrefixFrom(addr, 64).Masked().Addr().String()
|
||||||
|
}
|
||||||
|
return addr.String()
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ services:
|
|||||||
- "127.0.0.1:8080:8080"
|
- "127.0.0.1:8080:8080"
|
||||||
environment:
|
environment:
|
||||||
OAUTH_BASE_URL: https://ice.plezy.app
|
OAUTH_BASE_URL: https://ice.plezy.app
|
||||||
|
TRUSTED_PROXY_CIDRS: ${TRUSTED_PROXY_CIDRS:-}
|
||||||
MAL_CLIENT_ID: ${MAL_CLIENT_ID:-}
|
MAL_CLIENT_ID: ${MAL_CLIENT_ID:-}
|
||||||
ANILIST_CLIENT_ID: ${ANILIST_CLIENT_ID:-}
|
ANILIST_CLIENT_ID: ${ANILIST_CLIENT_ID:-}
|
||||||
ANILIST_CLIENT_SECRET: ${ANILIST_CLIENT_SECRET:-}
|
ANILIST_CLIENT_SECRET: ${ANILIST_CLIENT_SECRET:-}
|
||||||
|
|||||||
+1359
-314
File diff suppressed because it is too large
Load Diff
+4147
-141
File diff suppressed because it is too large
Load Diff
+182
-78
@@ -9,6 +9,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -30,7 +31,8 @@ const (
|
|||||||
oauthMaxSessions = 5000
|
oauthMaxSessions = 5000
|
||||||
oauthStartBurst = 3
|
oauthStartBurst = 3
|
||||||
oauthStartRateSustained = 1
|
oauthStartRateSustained = 1
|
||||||
oauthSessionIDBytes = 18 // 144 bits → 24 base64url chars
|
oauthBrowserStateBytes = 18 // 144 bits → 24 base64url chars
|
||||||
|
oauthPollSecretBytes = 18 // Independently generated device capability.
|
||||||
oauthPKCEVerifierLen = 64
|
oauthPKCEVerifierLen = 64
|
||||||
oauthUpstreamTimeout = 15 * time.Second
|
oauthUpstreamTimeout = 15 * time.Second
|
||||||
)
|
)
|
||||||
@@ -54,70 +56,88 @@ type oauthTokenResult struct {
|
|||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// oauthSession is created by /auth/start and lives until it's consumed by a
|
// oauthSession is created by /auth/start and lives until its result is claimed
|
||||||
// successful /auth/result (which deletes the map entry) or GC'd after
|
// or it is removed by cleanup. browserState crosses the browser/provider trust
|
||||||
// oauthSessionTTL. The `done` channel is closed exactly once (by complete) and
|
// boundary. Only the SHA-256 digest of the device-only poll secret is retained.
|
||||||
// unblocks waiters.
|
// When both locks are needed, oauthProxy.mu must be acquired before s.mu.
|
||||||
type oauthSession struct {
|
type oauthSession struct {
|
||||||
id string
|
browserState string
|
||||||
|
pollDigest [sha256.Size]byte
|
||||||
service string
|
service string
|
||||||
codeVerifier string // MAL PKCE; empty for AniList. Cleared after token exchange.
|
codeVerifier string // MAL PKCE; empty for AniList. Cleared after token exchange.
|
||||||
createdAt time.Time
|
createdAt time.Time
|
||||||
done chan struct{}
|
done chan struct{}
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
result *oauthTokenResult
|
completed bool
|
||||||
|
result *oauthTokenResult
|
||||||
|
|
||||||
|
// Test seam used to deterministically seat concurrent result waiters.
|
||||||
|
waitStarted func()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *oauthSession) complete(r oauthTokenResult) {
|
// completeLocked publishes at most one terminal result. The caller holds s.mu.
|
||||||
s.mu.Lock()
|
func (s *oauthSession) completeLocked(r oauthTokenResult) bool {
|
||||||
defer s.mu.Unlock()
|
if s.completed {
|
||||||
if s.result != nil {
|
return false
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
s.completed = true
|
||||||
s.result = &r
|
s.result = &r
|
||||||
s.codeVerifier = "" // Secret, not needed after exchange.
|
s.codeVerifier = "" // Secret, not needed after exchange.
|
||||||
close(s.done)
|
close(s.done)
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// wait blocks until the session is completed or ctx is cancelled.
|
// wait blocks only until the session is ready or ctx is cancelled. Result
|
||||||
func (s *oauthSession) wait(ctx context.Context) (*oauthTokenResult, error) {
|
// ownership is transferred separately by oauthProxy.claimResult.
|
||||||
|
func (s *oauthSession) wait(ctx context.Context) error {
|
||||||
|
if s.waitStarted != nil {
|
||||||
|
s.waitStarted()
|
||||||
|
}
|
||||||
select {
|
select {
|
||||||
case <-s.done:
|
case <-s.done:
|
||||||
s.mu.Lock()
|
return nil
|
||||||
defer s.mu.Unlock()
|
|
||||||
return s.result, nil
|
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return nil, ctx.Err()
|
return ctx.Err()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type oauthProxy struct {
|
func (s *oauthSession) pkceVerifier() string {
|
||||||
baseURL string // e.g. https://ice.plezy.app
|
s.mu.Lock()
|
||||||
services map[string]oauthServiceConfig
|
defer s.mu.Unlock()
|
||||||
client *http.Client
|
return s.codeVerifier
|
||||||
|
}
|
||||||
|
|
||||||
mu sync.Mutex
|
type oauthProxy struct {
|
||||||
sessions map[string]*oauthSession
|
baseURL string // e.g. https://ice.plezy.app
|
||||||
|
services map[string]oauthServiceConfig
|
||||||
|
client *http.Client
|
||||||
|
clientIPs clientIPResolver
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
browserStates map[string]*oauthSession
|
||||||
|
pollDigests map[[sha256.Size]byte]*oauthSession
|
||||||
|
|
||||||
ipMu sync.Mutex
|
ipMu sync.Mutex
|
||||||
ipRate map[string]*rateLimiter
|
ipRate map[string]*rateLimiter
|
||||||
}
|
}
|
||||||
|
|
||||||
func newOAuthProxy(baseURL string, services map[string]oauthServiceConfig) *oauthProxy {
|
func newOAuthProxy(baseURL string, services map[string]oauthServiceConfig, clientIPs clientIPResolver) *oauthProxy {
|
||||||
return &oauthProxy{
|
return &oauthProxy{
|
||||||
baseURL: strings.TrimRight(baseURL, "/"),
|
baseURL: strings.TrimRight(baseURL, "/"),
|
||||||
services: services,
|
services: services,
|
||||||
client: &http.Client{Timeout: oauthUpstreamTimeout},
|
client: &http.Client{Timeout: oauthUpstreamTimeout},
|
||||||
sessions: make(map[string]*oauthSession),
|
clientIPs: clientIPs,
|
||||||
ipRate: make(map[string]*rateLimiter),
|
browserStates: make(map[string]*oauthSession),
|
||||||
|
pollDigests: make(map[[sha256.Size]byte]*oauthSession),
|
||||||
|
ipRate: make(map[string]*rateLimiter),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// oauthConfigFromEnv reads the public base URL and per-service creds from the
|
// oauthConfigFromEnv reads the public base URL and per-service creds from the
|
||||||
// environment. Returns (nil, false) if OAUTH_BASE_URL is unset — the caller
|
// environment. Returns (nil, false) if OAUTH_BASE_URL is unset — the caller
|
||||||
// wires this as "OAuth disabled, endpoints return 503".
|
// wires this as "OAuth disabled, endpoints return 503".
|
||||||
func oauthConfigFromEnv() (*oauthProxy, bool) {
|
func oauthConfigFromEnv(clientIPs clientIPResolver) (*oauthProxy, bool) {
|
||||||
base := os.Getenv("OAUTH_BASE_URL")
|
base := os.Getenv("OAUTH_BASE_URL")
|
||||||
if base == "" {
|
if base == "" {
|
||||||
return nil, false
|
return nil, false
|
||||||
@@ -140,7 +160,7 @@ func oauthConfigFromEnv() (*oauthProxy, bool) {
|
|||||||
TokenURL: "https://anilist.co/api/v2/oauth/token",
|
TokenURL: "https://anilist.co/api/v2/oauth/token",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return newOAuthProxy(base, services), true
|
return newOAuthProxy(base, services, clientIPs), true
|
||||||
}
|
}
|
||||||
|
|
||||||
// registerOAuthRoutes registers all /auth/* handlers. If p is nil (no env
|
// registerOAuthRoutes registers all /auth/* handlers. If p is nil (no env
|
||||||
@@ -180,13 +200,18 @@ func (p *oauthProxy) handleAuthRoot(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// POST /auth/start body={"service":"mal"|"anilist"}
|
// POST /auth/start body={"service":"mal"|"anilist"}
|
||||||
// Response: {"session":"...","url":"https://.../auth/:service?session=...","expiresIn":600}
|
// Response: {"session":"device-only poll capability","url":"https://.../auth/:service?state=...","expiresIn":600}
|
||||||
func (p *oauthProxy) handleStart(w http.ResponseWriter, r *http.Request) {
|
func (p *oauthProxy) handleStart(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Cache-Control", "no-store, private")
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ip := clientIP(r)
|
ip, err := p.clientIPs.resolve(r)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid client address", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
if !p.ipAllow(ip) {
|
if !p.ipAllow(ip) {
|
||||||
http.Error(w, "Rate limited", http.StatusTooManyRequests)
|
http.Error(w, "Rate limited", http.StatusTooManyRequests)
|
||||||
return
|
return
|
||||||
@@ -205,36 +230,47 @@ func (p *oauthProxy) handleStart(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate tokens outside the map lock — crypto/rand syscalls would
|
// Generate independent trust-domain values outside the map lock —
|
||||||
// otherwise serialize concurrent /auth/start calls.
|
// crypto/rand syscalls must not serialize concurrent /auth/start calls.
|
||||||
sess := &oauthSession{
|
var pollSecret string
|
||||||
id: randToken(oauthSessionIDBytes),
|
var sess *oauthSession
|
||||||
service: body.Service,
|
for {
|
||||||
createdAt: time.Now(),
|
pollSecret = randToken(oauthPollSecretBytes)
|
||||||
done: make(chan struct{}),
|
sess = &oauthSession{
|
||||||
}
|
browserState: randToken(oauthBrowserStateBytes),
|
||||||
if cfg.UsePKCE {
|
pollDigest: digestPollSecret(pollSecret),
|
||||||
sess.codeVerifier = randPKCEVerifier()
|
service: body.Service,
|
||||||
}
|
createdAt: time.Now(),
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
if cfg.UsePKCE {
|
||||||
|
sess.codeVerifier = randPKCEVerifier()
|
||||||
|
}
|
||||||
|
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
if len(p.sessions) >= oauthMaxSessions {
|
if len(p.browserStates) >= oauthMaxSessions {
|
||||||
|
p.mu.Unlock()
|
||||||
|
http.Error(w, "Server busy", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if p.browserStates[sess.browserState] != nil || p.pollDigests[sess.pollDigest] != nil {
|
||||||
|
p.mu.Unlock()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p.addSessionLocked(sess)
|
||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
http.Error(w, "Server busy", http.StatusServiceUnavailable)
|
break
|
||||||
return
|
|
||||||
}
|
}
|
||||||
p.sessions[sess.id] = sess
|
|
||||||
p.mu.Unlock()
|
|
||||||
|
|
||||||
resp := map[string]any{
|
resp := map[string]any{
|
||||||
"session": sess.id,
|
"session": pollSecret,
|
||||||
"url": fmt.Sprintf("%s/auth/%s?session=%s", p.baseURL, url.PathEscape(body.Service), url.QueryEscape(sess.id)),
|
"url": fmt.Sprintf("%s/auth/%s?state=%s", p.baseURL, url.PathEscape(body.Service), url.QueryEscape(sess.browserState)),
|
||||||
"expiresIn": int(oauthSessionTTL.Seconds()),
|
"expiresIn": int(oauthSessionTTL.Seconds()),
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, resp)
|
writeJSON(w, http.StatusOK, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /auth/:service?session=X → 302 upstream authorize URL
|
// GET /auth/:service?state=X → 302 upstream authorize URL
|
||||||
func (p *oauthProxy) handleAuthorize(w http.ResponseWriter, r *http.Request, service string) {
|
func (p *oauthProxy) handleAuthorize(w http.ResponseWriter, r *http.Request, service string) {
|
||||||
if r.Method != http.MethodGet {
|
if r.Method != http.MethodGet {
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
@@ -245,9 +281,9 @@ func (p *oauthProxy) handleAuthorize(w http.ResponseWriter, r *http.Request, ser
|
|||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sessionID := r.URL.Query().Get("session")
|
browserState := r.URL.Query().Get("state")
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
sess := p.sessions[sessionID]
|
sess := p.browserStates[browserState]
|
||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
if sess == nil || sess.service != service {
|
if sess == nil || sess.service != service {
|
||||||
renderErrorPage(w, http.StatusNotFound, "This sign-in link is no longer valid. Start again from Plezy.")
|
renderErrorPage(w, http.StatusNotFound, "This sign-in link is no longer valid. Start again from Plezy.")
|
||||||
@@ -258,13 +294,13 @@ func (p *oauthProxy) handleAuthorize(w http.ResponseWriter, r *http.Request, ser
|
|||||||
"response_type": {"code"},
|
"response_type": {"code"},
|
||||||
"client_id": {cfg.ClientID},
|
"client_id": {cfg.ClientID},
|
||||||
"redirect_uri": {p.redirectURI(service)},
|
"redirect_uri": {p.redirectURI(service)},
|
||||||
"state": {sess.id},
|
"state": {sess.browserState},
|
||||||
}
|
}
|
||||||
if cfg.Scopes != "" {
|
if cfg.Scopes != "" {
|
||||||
q.Set("scope", cfg.Scopes)
|
q.Set("scope", cfg.Scopes)
|
||||||
}
|
}
|
||||||
if cfg.UsePKCE {
|
if cfg.UsePKCE {
|
||||||
q.Set("code_challenge", sess.codeVerifier) // plain method ⇒ challenge == verifier
|
q.Set("code_challenge", sess.pkceVerifier()) // plain method ⇒ challenge == verifier
|
||||||
q.Set("code_challenge_method", cfg.PKCEMethod)
|
q.Set("code_challenge_method", cfg.PKCEMethod)
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, cfg.AuthorizeURL+"?"+q.Encode(), http.StatusFound)
|
http.Redirect(w, r, cfg.AuthorizeURL+"?"+q.Encode(), http.StatusFound)
|
||||||
@@ -284,7 +320,7 @@ func (p *oauthProxy) handleCallback(w http.ResponseWriter, r *http.Request, serv
|
|||||||
q := r.URL.Query()
|
q := r.URL.Query()
|
||||||
state := q.Get("state")
|
state := q.Get("state")
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
sess := p.sessions[state]
|
sess := p.browserStates[state]
|
||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
if sess == nil || sess.service != service {
|
if sess == nil || sess.service != service {
|
||||||
renderErrorPage(w, http.StatusNotFound, "This sign-in link is no longer valid. Start again from Plezy.")
|
renderErrorPage(w, http.StatusNotFound, "This sign-in link is no longer valid. Start again from Plezy.")
|
||||||
@@ -292,13 +328,25 @@ func (p *oauthProxy) handleCallback(w http.ResponseWriter, r *http.Request, serv
|
|||||||
}
|
}
|
||||||
|
|
||||||
if upstreamErr := q.Get("error"); upstreamErr != "" {
|
if upstreamErr := q.Get("error"); upstreamErr != "" {
|
||||||
sess.complete(oauthTokenResult{Error: upstreamErr})
|
publicError := "authorization_failed"
|
||||||
renderErrorPage(w, http.StatusOK, "Sign-in was cancelled.")
|
message := "Sign-in failed. Please try again."
|
||||||
|
if upstreamErr == "access_denied" {
|
||||||
|
publicError = "access_denied"
|
||||||
|
message = "Sign-in was cancelled."
|
||||||
|
}
|
||||||
|
if !p.completeSession(sess, oauthTokenResult{Error: publicError}) {
|
||||||
|
renderErrorPage(w, http.StatusNotFound, "This sign-in link is no longer valid. Start again from Plezy.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
renderErrorPage(w, http.StatusOK, message)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
code := q.Get("code")
|
code := q.Get("code")
|
||||||
if code == "" {
|
if code == "" {
|
||||||
sess.complete(oauthTokenResult{Error: "missing_code"})
|
if !p.completeSession(sess, oauthTokenResult{Error: "missing_code"}) {
|
||||||
|
renderErrorPage(w, http.StatusNotFound, "This sign-in link is no longer valid. Start again from Plezy.")
|
||||||
|
return
|
||||||
|
}
|
||||||
renderErrorPage(w, http.StatusBadRequest, "Sign-in response was incomplete. Please try again.")
|
renderErrorPage(w, http.StatusBadRequest, "Sign-in response was incomplete. Please try again.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -306,23 +354,30 @@ func (p *oauthProxy) handleCallback(w http.ResponseWriter, r *http.Request, serv
|
|||||||
tok, err := p.exchangeCode(r.Context(), cfg, service, sess, code)
|
tok, err := p.exchangeCode(r.Context(), cfg, service, sess, code)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("oauth: %s token exchange failed: %v", service, err)
|
log.Printf("oauth: %s token exchange failed: %v", service, err)
|
||||||
sess.complete(oauthTokenResult{Error: "exchange_failed"})
|
if !p.completeSession(sess, oauthTokenResult{Error: "exchange_failed"}) {
|
||||||
|
renderErrorPage(w, http.StatusNotFound, "This sign-in link is no longer valid. Start again from Plezy.")
|
||||||
|
return
|
||||||
|
}
|
||||||
renderErrorPage(w, http.StatusBadGateway, "Couldn't complete sign-in. Please try again.")
|
renderErrorPage(w, http.StatusBadGateway, "Couldn't complete sign-in. Please try again.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sess.complete(tok)
|
if !p.completeSession(sess, tok) {
|
||||||
|
renderErrorPage(w, http.StatusNotFound, "This sign-in link is no longer valid. Start again from Plezy.")
|
||||||
|
return
|
||||||
|
}
|
||||||
renderSuccessPage(w)
|
renderSuccessPage(w)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /auth/result?session=X → long-poll, returns tokens on success
|
// GET /auth/result?session=X → long-poll, returns one terminal result.
|
||||||
func (p *oauthProxy) handleResult(w http.ResponseWriter, r *http.Request) {
|
func (p *oauthProxy) handleResult(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Cache-Control", "no-store, private")
|
||||||
if r.Method != http.MethodGet {
|
if r.Method != http.MethodGet {
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sessionID := r.URL.Query().Get("session")
|
pollDigest := digestPollSecret(r.URL.Query().Get("session"))
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
sess := p.sessions[sessionID]
|
sess := p.pollDigests[pollDigest]
|
||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
if sess == nil {
|
if sess == nil {
|
||||||
http.Error(w, "Session not found", http.StatusGone)
|
http.Error(w, "Session not found", http.StatusGone)
|
||||||
@@ -331,17 +386,16 @@ func (p *oauthProxy) handleResult(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), oauthResultWait)
|
ctx, cancel := context.WithTimeout(r.Context(), oauthResultWait)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
result, err := sess.wait(ctx)
|
if err := sess.wait(ctx); err != nil {
|
||||||
if err != nil {
|
|
||||||
// Client should retry — session may still receive its callback.
|
// Client should retry — session may still receive its callback.
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Session consumed — delete so a retry sees 410 instead of racing another wait.
|
result, ok := p.claimResult(pollDigest, sess)
|
||||||
p.mu.Lock()
|
if !ok {
|
||||||
delete(p.sessions, sess.id)
|
http.Error(w, "Session not found", http.StatusGone)
|
||||||
p.mu.Unlock()
|
return
|
||||||
|
}
|
||||||
if result.Error != "" {
|
if result.Error != "" {
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"error": result.Error})
|
writeJSON(w, http.StatusOK, map[string]any{"error": result.Error})
|
||||||
return
|
return
|
||||||
@@ -369,7 +423,7 @@ func (p *oauthProxy) exchangeCode(ctx context.Context, cfg oauthServiceConfig, s
|
|||||||
form.Set("client_secret", cfg.ClientSecret)
|
form.Set("client_secret", cfg.ClientSecret)
|
||||||
}
|
}
|
||||||
if cfg.UsePKCE {
|
if cfg.UsePKCE {
|
||||||
form.Set("code_verifier", sess.codeVerifier)
|
form.Set("code_verifier", sess.pkceVerifier())
|
||||||
}
|
}
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, cfg.TokenURL, strings.NewReader(form.Encode()))
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, cfg.TokenURL, strings.NewReader(form.Encode()))
|
||||||
@@ -410,13 +464,59 @@ func (p *oauthProxy) redirectURI(service string) string {
|
|||||||
return fmt.Sprintf("%s/auth/%s/callback", p.baseURL, service)
|
return fmt.Sprintf("%s/auth/%s/callback", p.baseURL, service)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// addSessionLocked installs both independently generated keys as one logical
|
||||||
|
// session. The caller has already verified that neither key is live.
|
||||||
|
func (p *oauthProxy) addSessionLocked(sess *oauthSession) {
|
||||||
|
p.browserStates[sess.browserState] = sess
|
||||||
|
p.pollDigests[sess.pollDigest] = sess
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeSessionLocked removes only entries still owned by sess, so a stale
|
||||||
|
// callback or waiter cannot remove a replacement.
|
||||||
|
func (p *oauthProxy) removeSessionLocked(sess *oauthSession) {
|
||||||
|
if p.browserStates[sess.browserState] == sess {
|
||||||
|
delete(p.browserStates, sess.browserState)
|
||||||
|
}
|
||||||
|
if p.pollDigests[sess.pollDigest] == sess {
|
||||||
|
delete(p.pollDigests, sess.pollDigest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *oauthProxy) completeSession(sess *oauthSession, result oauthTokenResult) bool {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
if p.browserStates[sess.browserState] != sess || p.pollDigests[sess.pollDigest] != sess {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
sess.mu.Lock()
|
||||||
|
defer sess.mu.Unlock()
|
||||||
|
return sess.completeLocked(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *oauthProxy) claimResult(digest [sha256.Size]byte, sess *oauthSession) (oauthTokenResult, bool) {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
if p.pollDigests[digest] != sess || p.browserStates[sess.browserState] != sess {
|
||||||
|
return oauthTokenResult{}, false
|
||||||
|
}
|
||||||
|
sess.mu.Lock()
|
||||||
|
defer sess.mu.Unlock()
|
||||||
|
if sess.result == nil {
|
||||||
|
return oauthTokenResult{}, false
|
||||||
|
}
|
||||||
|
result := *sess.result
|
||||||
|
sess.result = nil
|
||||||
|
p.removeSessionLocked(sess)
|
||||||
|
return result, true
|
||||||
|
}
|
||||||
|
|
||||||
// cleanup drops sessions past oauthSessionTTL. Called by the main cleanup loop.
|
// cleanup drops sessions past oauthSessionTTL. Called by the main cleanup loop.
|
||||||
func (p *oauthProxy) cleanup() {
|
func (p *oauthProxy) cleanup() {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
for id, sess := range p.sessions {
|
for _, sess := range p.browserStates {
|
||||||
if now.Sub(sess.createdAt) > oauthSessionTTL {
|
if now.Sub(sess.createdAt) > oauthSessionTTL {
|
||||||
delete(p.sessions, id)
|
p.removeSessionLocked(sess)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
@@ -463,6 +563,10 @@ func writeJSON(w http.ResponseWriter, status int, v any) {
|
|||||||
_ = json.NewEncoder(w).Encode(v)
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func digestPollSecret(secret string) [sha256.Size]byte {
|
||||||
|
return sha256.Sum256([]byte(secret))
|
||||||
|
}
|
||||||
|
|
||||||
func randToken(numBytes int) string {
|
func randToken(numBytes int) string {
|
||||||
b := make([]byte, numBytes)
|
b := make([]byte, numBytes)
|
||||||
if _, err := rand.Read(b); err != nil {
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
|||||||
+402
-152
@@ -104,13 +104,18 @@ func (m *mockUpstream) form() url.Values {
|
|||||||
// newOAuthHarness boots a relay-less httptest server that mounts /auth/* only,
|
// newOAuthHarness boots a relay-less httptest server that mounts /auth/* only,
|
||||||
// with `mal` and `anilist` services pointed at a shared mock upstream.
|
// with `mal` and `anilist` services pointed at a shared mock upstream.
|
||||||
type oauthHarness struct {
|
type oauthHarness struct {
|
||||||
proxy *oauthProxy
|
proxy *oauthProxy
|
||||||
srv *httptest.Server
|
srv *httptest.Server
|
||||||
base string
|
base string
|
||||||
upstream *mockUpstream
|
upstream *mockUpstream
|
||||||
}
|
}
|
||||||
|
|
||||||
func newOAuthHarness(t *testing.T) *oauthHarness {
|
func newOAuthHarness(t *testing.T) *oauthHarness {
|
||||||
|
t.Helper()
|
||||||
|
return newOAuthHarnessWithResolver(t, mustClientIPResolver(t, "127.0.0.0/8"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func newOAuthHarnessWithResolver(t *testing.T, clientIPs clientIPResolver) *oauthHarness {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
up := newMockUpstream(t)
|
up := newMockUpstream(t)
|
||||||
proxy := newOAuthProxy("http://placeholder", map[string]oauthServiceConfig{
|
proxy := newOAuthProxy("http://placeholder", map[string]oauthServiceConfig{
|
||||||
@@ -127,7 +132,7 @@ func newOAuthHarness(t *testing.T) *oauthHarness {
|
|||||||
AuthorizeURL: up.srv.URL + "/oauth/authorize",
|
AuthorizeURL: up.srv.URL + "/oauth/authorize",
|
||||||
TokenURL: up.srv.URL + "/oauth/token",
|
TokenURL: up.srv.URL + "/oauth/token",
|
||||||
},
|
},
|
||||||
})
|
}, clientIPs)
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
registerOAuthRoutes(mux, proxy)
|
registerOAuthRoutes(mux, proxy)
|
||||||
srv := httptest.NewServer(mux)
|
srv := httptest.NewServer(mux)
|
||||||
@@ -137,7 +142,7 @@ func newOAuthHarness(t *testing.T) *oauthHarness {
|
|||||||
return &oauthHarness{proxy: proxy, srv: srv, base: srv.URL, upstream: up}
|
return &oauthHarness{proxy: proxy, srv: srv, base: srv.URL, upstream: up}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *oauthHarness) startSession(t *testing.T, service, ip string) (sessionID, qrURL string) {
|
func (h *oauthHarness) startSession(t *testing.T, service, ip string) (pollSecret, browserState, authorizeURL string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
body, _ := json.Marshal(map[string]string{"service": service})
|
body, _ := json.Marshal(map[string]string{"service": service})
|
||||||
req, _ := http.NewRequest(http.MethodPost, h.base+"/auth/start", bytes.NewReader(body))
|
req, _ := http.NewRequest(http.MethodPost, h.base+"/auth/start", bytes.NewReader(body))
|
||||||
@@ -153,6 +158,9 @@ func (h *oauthHarness) startSession(t *testing.T, service, ip string) (sessionID
|
|||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
t.Fatalf("start status=%d", resp.StatusCode)
|
t.Fatalf("start status=%d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
if got := resp.Header.Get("Cache-Control"); got != "no-store, private" {
|
||||||
|
t.Fatalf("start Cache-Control=%q", got)
|
||||||
|
}
|
||||||
var out struct {
|
var out struct {
|
||||||
Session string `json:"session"`
|
Session string `json:"session"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
@@ -161,22 +169,58 @@ func (h *oauthHarness) startSession(t *testing.T, service, ip string) (sessionID
|
|||||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||||
t.Fatalf("decode: %v", err)
|
t.Fatalf("decode: %v", err)
|
||||||
}
|
}
|
||||||
if out.Session == "" || out.URL == "" {
|
parsed, err := url.Parse(out.URL)
|
||||||
t.Fatalf("empty session/url: %+v", out)
|
if err != nil {
|
||||||
|
t.Fatalf("parse authorize URL: %v", err)
|
||||||
}
|
}
|
||||||
return out.Session, out.URL
|
state := parsed.Query().Get("state")
|
||||||
|
if out.Session == "" || out.URL == "" || state == "" {
|
||||||
|
t.Fatalf("empty poll secret/url/browser state: %+v", out)
|
||||||
|
}
|
||||||
|
return out.Session, state, out.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
func postOAuthStart(t *testing.T, h *oauthHarness, service, xff string) *http.Response {
|
||||||
|
t.Helper()
|
||||||
|
body, err := json.Marshal(map[string]string{"service": service})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest(http.MethodPost, h.base+"/auth/start", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new request: %v", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if xff != "" {
|
||||||
|
req.Header.Set("X-Forwarded-For", xff)
|
||||||
|
}
|
||||||
|
return httpDo(t, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ====== /auth/start ======
|
// ====== /auth/start ======
|
||||||
|
|
||||||
func TestOAuthStartReturnsSessionAndURL(t *testing.T) {
|
func TestOAuthStartSeparatesDeviceCapabilityFromBrowserState(t *testing.T) {
|
||||||
h := newOAuthHarness(t)
|
h := newOAuthHarness(t)
|
||||||
session, qr := h.startSession(t, "mal", "1.2.3.4")
|
pollSecret, browserState, authorizeURL := h.startSession(t, "mal", "1.2.3.4")
|
||||||
if !strings.HasPrefix(qr, h.base+"/auth/mal?session=") {
|
if pollSecret == browserState {
|
||||||
t.Fatalf("url=%q doesn't look like the authorize start URL", qr)
|
t.Fatal("device poll capability must differ from browser state")
|
||||||
}
|
}
|
||||||
if !strings.Contains(qr, url.QueryEscape(session)) {
|
if !strings.HasPrefix(authorizeURL, h.base+"/auth/mal?state=") {
|
||||||
t.Fatalf("url=%q missing session token", qr)
|
t.Fatalf("url=%q doesn't look like the authorize start URL", authorizeURL)
|
||||||
|
}
|
||||||
|
if strings.Contains(authorizeURL, pollSecret) {
|
||||||
|
t.Fatalf("authorize URL disclosed device poll capability")
|
||||||
|
}
|
||||||
|
if got := h.proxy.pollDigests[digestPollSecret(pollSecret)]; got == nil {
|
||||||
|
t.Fatal("poll capability digest was not indexed")
|
||||||
|
}
|
||||||
|
if got := h.proxy.browserStates[browserState]; got == nil {
|
||||||
|
t.Fatal("browser state was not indexed")
|
||||||
|
}
|
||||||
|
pollAsBrowser := httpGet(t, h.base+"/auth/mal?state="+url.QueryEscape(pollSecret))
|
||||||
|
pollAsBrowser.Body.Close()
|
||||||
|
if pollAsBrowser.StatusCode != http.StatusNotFound {
|
||||||
|
t.Fatalf("poll capability authorized browser path: status=%d", pollAsBrowser.StatusCode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,8 +246,8 @@ func TestOAuthStartRejectsInvalidJSON(t *testing.T) {
|
|||||||
func TestOAuthStartRateLimitedPerIP(t *testing.T) {
|
func TestOAuthStartRateLimitedPerIP(t *testing.T) {
|
||||||
h := newOAuthHarness(t)
|
h := newOAuthHarness(t)
|
||||||
ip := "5.5.5.5"
|
ip := "5.5.5.5"
|
||||||
for i := 0; i < oauthStartBurst; i++ {
|
for range oauthStartBurst {
|
||||||
h.startSession(t, "mal", ip) // should all succeed
|
_, _, _ = h.startSession(t, "mal", ip) // should all succeed
|
||||||
}
|
}
|
||||||
body, _ := json.Marshal(map[string]string{"service": "mal"})
|
body, _ := json.Marshal(map[string]string{"service": "mal"})
|
||||||
req, err := http.NewRequest(http.MethodPost, h.base+"/auth/start", bytes.NewReader(body))
|
req, err := http.NewRequest(http.MethodPost, h.base+"/auth/start", bytes.NewReader(body))
|
||||||
@@ -219,6 +263,98 @@ func TestOAuthStartRateLimitedPerIP(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOAuthSessionLimitCountsLogicalSessions(t *testing.T) {
|
||||||
|
h := newOAuthHarness(t)
|
||||||
|
h.proxy.mu.Lock()
|
||||||
|
for i := range oauthMaxSessions - 1 {
|
||||||
|
sess := &oauthSession{
|
||||||
|
browserState: fmt.Sprintf("state-%d", i),
|
||||||
|
pollDigest: digestPollSecret(fmt.Sprintf("poll-%d", i)),
|
||||||
|
}
|
||||||
|
h.proxy.addSessionLocked(sess)
|
||||||
|
}
|
||||||
|
h.proxy.mu.Unlock()
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]string{"service": "mal"})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/auth/start", bytes.NewReader(body))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.proxy.handleStart(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("start with %d logical sessions: status=%d want 200", oauthMaxSessions-1, rec.Code)
|
||||||
|
}
|
||||||
|
h.proxy.mu.Lock()
|
||||||
|
browserCount := len(h.proxy.browserStates)
|
||||||
|
pollCount := len(h.proxy.pollDigests)
|
||||||
|
h.proxy.mu.Unlock()
|
||||||
|
if browserCount != oauthMaxSessions || pollCount != oauthMaxSessions {
|
||||||
|
t.Fatalf("index counts browser=%d poll=%d want %d each", browserCount, pollCount, oauthMaxSessions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOAuthStartUsesTrustedCanonicalClientIdentity(t *testing.T) {
|
||||||
|
t.Run("untrusted spoof rotation shares direct peer bucket", func(t *testing.T) {
|
||||||
|
h := newOAuthHarnessWithResolver(t, newClientIPResolver(nil))
|
||||||
|
for i := range oauthStartBurst {
|
||||||
|
resp := postOAuthStart(t, h, "mal", fmt.Sprintf("203.0.113.%d", i+1))
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("start %d status=%d", i, resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
denied := postOAuthStart(t, h, "mal", "198.51.100.10")
|
||||||
|
denied.Body.Close()
|
||||||
|
if denied.StatusCode != http.StatusTooManyRequests {
|
||||||
|
t.Fatalf("rotated spoof status=%d, want 429", denied.StatusCode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("validated clients have independent buckets", func(t *testing.T) {
|
||||||
|
h := newOAuthHarness(t)
|
||||||
|
for _, ip := range []string{"203.0.113.1", "203.0.113.2"} {
|
||||||
|
for range oauthStartBurst {
|
||||||
|
resp := postOAuthStart(t, h, "mal", ip)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("client %s status=%d", ip, resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("malformed trusted chain mutates no state", func(t *testing.T) {
|
||||||
|
h := newOAuthHarness(t)
|
||||||
|
resp := postOAuthStart(t, h, "mal", "203.0.113.1,")
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status=%d, want 400", resp.StatusCode)
|
||||||
|
}
|
||||||
|
h.proxy.ipMu.Lock()
|
||||||
|
rateCount := len(h.proxy.ipRate)
|
||||||
|
h.proxy.ipMu.Unlock()
|
||||||
|
h.proxy.mu.Lock()
|
||||||
|
browserCount := len(h.proxy.browserStates)
|
||||||
|
pollCount := len(h.proxy.pollDigests)
|
||||||
|
h.proxy.mu.Unlock()
|
||||||
|
if rateCount != 0 || browserCount != 0 || pollCount != 0 {
|
||||||
|
t.Fatalf(
|
||||||
|
"malformed chain mutated OAuth state: rates=%d browser=%d poll=%d",
|
||||||
|
rateCount,
|
||||||
|
browserCount,
|
||||||
|
pollCount,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("untrusted malformed header is ignored", func(t *testing.T) {
|
||||||
|
h := newOAuthHarnessWithResolver(t, newClientIPResolver(nil))
|
||||||
|
resp := postOAuthStart(t, h, "mal", "bad,")
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("status=%d, want 200", resp.StatusCode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestOAuthStartMethodNotAllowed(t *testing.T) {
|
func TestOAuthStartMethodNotAllowed(t *testing.T) {
|
||||||
h := newOAuthHarness(t)
|
h := newOAuthHarness(t)
|
||||||
resp := httpGet(t, h.base+"/auth/start")
|
resp := httpGet(t, h.base+"/auth/start")
|
||||||
@@ -232,10 +368,10 @@ func TestOAuthStartMethodNotAllowed(t *testing.T) {
|
|||||||
|
|
||||||
func TestOAuthAuthorizeMALRedirectIncludesPKCE(t *testing.T) {
|
func TestOAuthAuthorizeMALRedirectIncludesPKCE(t *testing.T) {
|
||||||
h := newOAuthHarness(t)
|
h := newOAuthHarness(t)
|
||||||
sess, _ := h.startSession(t, "mal", "1.1.1.1")
|
pollSecret, browserState, _ := h.startSession(t, "mal", "1.1.1.1")
|
||||||
|
|
||||||
client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
||||||
resp, err := client.Get(h.base + "/auth/mal?session=" + url.QueryEscape(sess))
|
resp, err := client.Get(h.base + "/auth/mal?state=" + url.QueryEscape(browserState))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("get: %v", err)
|
t.Fatalf("get: %v", err)
|
||||||
}
|
}
|
||||||
@@ -254,8 +390,11 @@ func TestOAuthAuthorizeMALRedirectIncludesPKCE(t *testing.T) {
|
|||||||
if q.Get("response_type") != "code" {
|
if q.Get("response_type") != "code" {
|
||||||
t.Errorf("response_type=%q", q.Get("response_type"))
|
t.Errorf("response_type=%q", q.Get("response_type"))
|
||||||
}
|
}
|
||||||
if q.Get("state") != sess {
|
if q.Get("state") != browserState {
|
||||||
t.Errorf("state=%q, want session %q", q.Get("state"), sess)
|
t.Errorf("state=%q, want browser state %q", q.Get("state"), browserState)
|
||||||
|
}
|
||||||
|
if q.Get("state") == pollSecret {
|
||||||
|
t.Error("provider state disclosed device poll capability")
|
||||||
}
|
}
|
||||||
if q.Get("code_challenge_method") != "plain" {
|
if q.Get("code_challenge_method") != "plain" {
|
||||||
t.Errorf("code_challenge_method=%q, want plain", q.Get("code_challenge_method"))
|
t.Errorf("code_challenge_method=%q, want plain", q.Get("code_challenge_method"))
|
||||||
@@ -270,9 +409,9 @@ func TestOAuthAuthorizeMALRedirectIncludesPKCE(t *testing.T) {
|
|||||||
|
|
||||||
func TestOAuthAuthorizeAnilistRedirectOmitsPKCE(t *testing.T) {
|
func TestOAuthAuthorizeAnilistRedirectOmitsPKCE(t *testing.T) {
|
||||||
h := newOAuthHarness(t)
|
h := newOAuthHarness(t)
|
||||||
sess, _ := h.startSession(t, "anilist", "1.1.1.2")
|
_, browserState, _ := h.startSession(t, "anilist", "1.1.1.2")
|
||||||
client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
||||||
resp, err := client.Get(h.base + "/auth/anilist?session=" + url.QueryEscape(sess))
|
resp, err := client.Get(h.base + "/auth/anilist?state=" + url.QueryEscape(browserState))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("get: %v", err)
|
t.Fatalf("get: %v", err)
|
||||||
}
|
}
|
||||||
@@ -286,7 +425,7 @@ func TestOAuthAuthorizeAnilistRedirectOmitsPKCE(t *testing.T) {
|
|||||||
|
|
||||||
func TestOAuthAuthorizeUnknownSessionRendersError(t *testing.T) {
|
func TestOAuthAuthorizeUnknownSessionRendersError(t *testing.T) {
|
||||||
h := newOAuthHarness(t)
|
h := newOAuthHarness(t)
|
||||||
resp := httpGet(t, h.base+"/auth/mal?session=bogus")
|
resp := httpGet(t, h.base+"/auth/mal?state=bogus")
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode != http.StatusNotFound {
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
t.Fatalf("status=%d want 404", resp.StatusCode)
|
t.Fatalf("status=%d want 404", resp.StatusCode)
|
||||||
@@ -299,9 +438,9 @@ func TestOAuthAuthorizeUnknownSessionRendersError(t *testing.T) {
|
|||||||
|
|
||||||
func TestOAuthAuthorizeWrongServiceRejected(t *testing.T) {
|
func TestOAuthAuthorizeWrongServiceRejected(t *testing.T) {
|
||||||
h := newOAuthHarness(t)
|
h := newOAuthHarness(t)
|
||||||
sess, _ := h.startSession(t, "mal", "1.1.1.3")
|
_, browserState, _ := h.startSession(t, "mal", "1.1.1.3")
|
||||||
// Try to use the MAL session against the AniList authorize endpoint.
|
// Try to use the MAL browser state against the AniList authorize endpoint.
|
||||||
resp := httpGet(t, h.base+"/auth/anilist?session="+url.QueryEscape(sess))
|
resp := httpGet(t, h.base+"/auth/anilist?state="+url.QueryEscape(browserState))
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode != http.StatusNotFound {
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
t.Fatalf("status=%d want 404", resp.StatusCode)
|
t.Fatalf("status=%d want 404", resp.StatusCode)
|
||||||
@@ -310,117 +449,238 @@ func TestOAuthAuthorizeWrongServiceRejected(t *testing.T) {
|
|||||||
|
|
||||||
// ====== /auth/:service/callback + /auth/result ======
|
// ====== /auth/:service/callback + /auth/result ======
|
||||||
|
|
||||||
func TestOAuthCallbackExchangesCodeAndResultReturnsTokens(t *testing.T) {
|
type oauthResultResponse struct {
|
||||||
h := newOAuthHarness(t)
|
status int
|
||||||
sess, _ := h.startSession(t, "mal", "2.2.2.1")
|
cacheControl string
|
||||||
|
body map[string]any
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
resultCh := make(chan map[string]any, 1)
|
func requestOAuthResult(rawURL string) oauthResultResponse {
|
||||||
go func() {
|
resp, err := http.Get(rawURL)
|
||||||
resp, err := http.Get(h.base + "/auth/result?session=" + url.QueryEscape(sess))
|
|
||||||
if err != nil {
|
|
||||||
resultCh <- map[string]any{"_err": err.Error()}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
var m map[string]any
|
|
||||||
_ = json.NewDecoder(resp.Body).Decode(&m)
|
|
||||||
resultCh <- m
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Hit the callback as the upstream browser would.
|
|
||||||
cbURL := fmt.Sprintf("%s/auth/mal/callback?code=CODE123&state=%s", h.base, url.QueryEscape(sess))
|
|
||||||
resp, err := http.Get(cbURL)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("callback: %v", err)
|
return oauthResultResponse{err: err}
|
||||||
}
|
}
|
||||||
resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode != http.StatusOK {
|
out := oauthResultResponse{
|
||||||
t.Fatalf("callback status=%d", resp.StatusCode)
|
status: resp.StatusCode,
|
||||||
|
cacheControl: resp.Header.Get("Cache-Control"),
|
||||||
}
|
}
|
||||||
|
if resp.StatusCode == http.StatusOK {
|
||||||
|
out.body = make(map[string]any)
|
||||||
|
out.err = json.NewDecoder(resp.Body).Decode(&out.body)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// Upstream should have been called with PKCE + code.
|
func TestOAuthBrowserStateCannotClaimResult(t *testing.T) {
|
||||||
form := h.upstream.form()
|
for _, service := range []string{"mal", "anilist"} {
|
||||||
if form.Get("code") != "CODE123" {
|
t.Run(service, func(t *testing.T) {
|
||||||
t.Errorf("upstream code=%q", form.Get("code"))
|
h := newOAuthHarness(t)
|
||||||
}
|
pollSecret, browserState, _ := h.startSession(t, service, "2.2.2.1")
|
||||||
if form.Get("code_verifier") == "" {
|
|
||||||
t.Error("upstream missing code_verifier (PKCE)")
|
|
||||||
}
|
|
||||||
if form.Get("grant_type") != "authorization_code" {
|
|
||||||
t.Errorf("grant_type=%q", form.Get("grant_type"))
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
browserClaim := requestOAuthResult(h.base + "/auth/result?session=" + url.QueryEscape(browserState))
|
||||||
case got := <-resultCh:
|
if browserClaim.err != nil {
|
||||||
if got["accessToken"] != "tok-abc" {
|
t.Fatalf("browser-state result request: %v", browserClaim.err)
|
||||||
t.Errorf("accessToken=%v want tok-abc", got["accessToken"])
|
}
|
||||||
}
|
if browserClaim.status != http.StatusGone {
|
||||||
if got["refreshToken"] != "ref-xyz" {
|
t.Fatalf("browser-state result status=%d want 410", browserClaim.status)
|
||||||
t.Errorf("refreshToken=%v want ref-xyz", got["refreshToken"])
|
}
|
||||||
}
|
|
||||||
case <-time.After(3 * time.Second):
|
callback := fmt.Sprintf("%s/auth/%s/callback?code=CODE123&state=%s", h.base, service, url.QueryEscape(browserState))
|
||||||
t.Fatal("result never returned")
|
resp := httpGet(t, callback)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("callback status=%d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := requestOAuthResult(h.base + "/auth/result?session=" + url.QueryEscape(pollSecret))
|
||||||
|
if result.err != nil {
|
||||||
|
t.Fatalf("device result request: %v", result.err)
|
||||||
|
}
|
||||||
|
if result.status != http.StatusOK {
|
||||||
|
t.Fatalf("device result status=%d want 200", result.status)
|
||||||
|
}
|
||||||
|
if result.cacheControl != "no-store, private" {
|
||||||
|
t.Fatalf("result Cache-Control=%q", result.cacheControl)
|
||||||
|
}
|
||||||
|
if result.body["accessToken"] != "tok-abc" || result.body["refreshToken"] != "ref-xyz" {
|
||||||
|
t.Fatalf("unexpected result: %v", result.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
second := requestOAuthResult(h.base + "/auth/result?session=" + url.QueryEscape(pollSecret))
|
||||||
|
if second.err != nil {
|
||||||
|
t.Fatalf("second result request: %v", second.err)
|
||||||
|
}
|
||||||
|
if second.status != http.StatusGone {
|
||||||
|
t.Fatalf("second result status=%d want 410", second.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
form := h.upstream.form()
|
||||||
|
if form.Get("code") != "CODE123" {
|
||||||
|
t.Errorf("upstream code=%q", form.Get("code"))
|
||||||
|
}
|
||||||
|
if service == "mal" && form.Get("code_verifier") == "" {
|
||||||
|
t.Error("upstream missing MAL code verifier")
|
||||||
|
}
|
||||||
|
if service == "anilist" && form.Get("code_verifier") != "" {
|
||||||
|
t.Error("AniList exchange unexpectedly included a code verifier")
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestOAuthCallbackUpstreamError(t *testing.T) {
|
func TestOAuthConcurrentResultClaimIsOneShot(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
result oauthTokenResult
|
||||||
|
}{
|
||||||
|
{name: "token", result: oauthTokenResult{AccessToken: "tok"}},
|
||||||
|
{name: "provider error", result: oauthTokenResult{Error: "authorization_failed"}},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
h := newOAuthHarness(t)
|
||||||
|
pollSecret, browserState, _ := h.startSession(t, "mal", "2.2.2.2")
|
||||||
|
digest := digestPollSecret(pollSecret)
|
||||||
|
|
||||||
|
h.proxy.mu.Lock()
|
||||||
|
sess := h.proxy.pollDigests[digest]
|
||||||
|
h.proxy.mu.Unlock()
|
||||||
|
if sess == nil {
|
||||||
|
t.Fatal("session missing from poll index")
|
||||||
|
}
|
||||||
|
|
||||||
|
seated := make(chan struct{}, 2)
|
||||||
|
sess.waitStarted = func() { seated <- struct{}{} }
|
||||||
|
results := make(chan oauthResultResponse, 2)
|
||||||
|
resultURL := h.base + "/auth/result?session=" + url.QueryEscape(pollSecret)
|
||||||
|
go func() { results <- requestOAuthResult(resultURL) }()
|
||||||
|
go func() { results <- requestOAuthResult(resultURL) }()
|
||||||
|
|
||||||
|
for range 2 {
|
||||||
|
select {
|
||||||
|
case <-seated:
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("result waiter did not reach readiness boundary")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !h.proxy.completeSession(sess, tc.result) {
|
||||||
|
t.Fatal("could not complete current session")
|
||||||
|
}
|
||||||
|
|
||||||
|
statuses := map[int]int{}
|
||||||
|
var winningBody map[string]any
|
||||||
|
for range 2 {
|
||||||
|
select {
|
||||||
|
case got := <-results:
|
||||||
|
if got.err != nil {
|
||||||
|
t.Fatalf("result request: %v", got.err)
|
||||||
|
}
|
||||||
|
if got.cacheControl != "no-store, private" {
|
||||||
|
t.Errorf("result Cache-Control=%q", got.cacheControl)
|
||||||
|
}
|
||||||
|
statuses[got.status]++
|
||||||
|
if got.status == http.StatusOK {
|
||||||
|
winningBody = got.body
|
||||||
|
}
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("result request did not return")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if statuses[http.StatusOK] != 1 || statuses[http.StatusGone] != 1 {
|
||||||
|
t.Fatalf("statuses=%v want one 200 and one 410", statuses)
|
||||||
|
}
|
||||||
|
if tc.result.Error != "" && winningBody["error"] != tc.result.Error {
|
||||||
|
t.Fatalf("winning error result=%v", winningBody)
|
||||||
|
}
|
||||||
|
if tc.result.AccessToken != "" && winningBody["accessToken"] != tc.result.AccessToken {
|
||||||
|
t.Fatalf("winning token result=%v", winningBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
h.proxy.mu.Lock()
|
||||||
|
_, hasBrowserState := h.proxy.browserStates[browserState]
|
||||||
|
_, hasPollDigest := h.proxy.pollDigests[digest]
|
||||||
|
h.proxy.mu.Unlock()
|
||||||
|
sess.mu.Lock()
|
||||||
|
storedResult := sess.result
|
||||||
|
sess.mu.Unlock()
|
||||||
|
if hasBrowserState || hasPollDigest || storedResult != nil {
|
||||||
|
t.Fatalf("claim left state: browser=%v poll=%v result=%v", hasBrowserState, hasPollDigest, storedResult)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOAuthCallbackErrorsAreGenericAndOneShot(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
callback func(base, state string) string
|
||||||
|
wantError string
|
||||||
|
wantCBState int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "provider detail",
|
||||||
|
callback: func(base, state string) string {
|
||||||
|
return fmt.Sprintf("%s/auth/mal/callback?error=provider_detail_canary&state=%s", base, url.QueryEscape(state))
|
||||||
|
},
|
||||||
|
wantError: "authorization_failed",
|
||||||
|
wantCBState: http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "user cancelled",
|
||||||
|
callback: func(base, state string) string {
|
||||||
|
return fmt.Sprintf("%s/auth/mal/callback?error=access_denied&state=%s", base, url.QueryEscape(state))
|
||||||
|
},
|
||||||
|
wantError: "access_denied",
|
||||||
|
wantCBState: http.StatusOK,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
h := newOAuthHarness(t)
|
||||||
|
pollSecret, browserState, _ := h.startSession(t, "mal", "2.2.2.3")
|
||||||
|
resp := httpGet(t, tc.callback(h.base, browserState))
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != tc.wantCBState {
|
||||||
|
t.Fatalf("callback status=%d want %d", resp.StatusCode, tc.wantCBState)
|
||||||
|
}
|
||||||
|
result := requestOAuthResult(h.base + "/auth/result?session=" + url.QueryEscape(pollSecret))
|
||||||
|
if result.err != nil {
|
||||||
|
t.Fatalf("result request: %v", result.err)
|
||||||
|
}
|
||||||
|
if result.status != http.StatusOK || result.body["error"] != tc.wantError {
|
||||||
|
t.Fatalf("result status=%d body=%v", result.status, result.body)
|
||||||
|
}
|
||||||
|
if result.cacheControl != "no-store, private" {
|
||||||
|
t.Fatalf("result Cache-Control=%q", result.cacheControl)
|
||||||
|
}
|
||||||
|
second := requestOAuthResult(h.base + "/auth/result?session=" + url.QueryEscape(pollSecret))
|
||||||
|
if second.status != http.StatusGone {
|
||||||
|
t.Fatalf("second result status=%d want 410", second.status)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOAuthCallbackExchangeFailureIsOneShot(t *testing.T) {
|
||||||
h := newOAuthHarness(t)
|
h := newOAuthHarness(t)
|
||||||
h.upstream.setReply(http.StatusBadRequest, `{"error":"invalid_grant"}`)
|
h.upstream.setReply(http.StatusBadRequest, `{"error":"invalid_grant"}`)
|
||||||
sess, _ := h.startSession(t, "mal", "2.2.2.2")
|
pollSecret, browserState, _ := h.startSession(t, "mal", "2.2.2.4")
|
||||||
|
|
||||||
resultCh := make(chan map[string]any, 1)
|
resp := httpGet(t, fmt.Sprintf("%s/auth/mal/callback?code=CODE&state=%s", h.base, url.QueryEscape(browserState)))
|
||||||
go func() {
|
|
||||||
resp, err := http.Get(h.base + "/auth/result?session=" + url.QueryEscape(sess))
|
|
||||||
if err != nil {
|
|
||||||
resultCh <- map[string]any{"_err": err.Error()}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
var m map[string]any
|
|
||||||
_ = json.NewDecoder(resp.Body).Decode(&m)
|
|
||||||
resultCh <- m
|
|
||||||
}()
|
|
||||||
|
|
||||||
resp := httpGet(t, fmt.Sprintf("%s/auth/mal/callback?code=CODE&state=%s", h.base, url.QueryEscape(sess)))
|
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusBadGateway {
|
||||||
select {
|
t.Fatalf("callback status=%d want 502", resp.StatusCode)
|
||||||
case got := <-resultCh:
|
|
||||||
if got["error"] != "exchange_failed" {
|
|
||||||
t.Errorf("expected error=exchange_failed, got %v", got)
|
|
||||||
}
|
|
||||||
case <-time.After(3 * time.Second):
|
|
||||||
t.Fatal("result never returned")
|
|
||||||
}
|
}
|
||||||
}
|
result := requestOAuthResult(h.base + "/auth/result?session=" + url.QueryEscape(pollSecret))
|
||||||
|
if result.status != http.StatusOK || result.body["error"] != "exchange_failed" {
|
||||||
func TestOAuthCallbackUserCancelled(t *testing.T) {
|
t.Fatalf("result status=%d body=%v", result.status, result.body)
|
||||||
h := newOAuthHarness(t)
|
}
|
||||||
sess, _ := h.startSession(t, "mal", "2.2.2.3")
|
if result.cacheControl != "no-store, private" {
|
||||||
|
t.Fatalf("result Cache-Control=%q", result.cacheControl)
|
||||||
resultCh := make(chan map[string]any, 1)
|
}
|
||||||
go func() {
|
second := requestOAuthResult(h.base + "/auth/result?session=" + url.QueryEscape(pollSecret))
|
||||||
resp, err := http.Get(h.base + "/auth/result?session=" + url.QueryEscape(sess))
|
if second.status != http.StatusGone {
|
||||||
if err != nil {
|
t.Fatalf("second result status=%d want 410", second.status)
|
||||||
resultCh <- map[string]any{"_err": err.Error()}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
var m map[string]any
|
|
||||||
_ = json.NewDecoder(resp.Body).Decode(&m)
|
|
||||||
resultCh <- m
|
|
||||||
}()
|
|
||||||
|
|
||||||
resp := httpGet(t, fmt.Sprintf("%s/auth/mal/callback?error=access_denied&state=%s", h.base, url.QueryEscape(sess)))
|
|
||||||
resp.Body.Close()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case got := <-resultCh:
|
|
||||||
if got["error"] != "access_denied" {
|
|
||||||
t.Errorf("expected error=access_denied, got %v", got)
|
|
||||||
}
|
|
||||||
case <-time.After(3 * time.Second):
|
|
||||||
t.Fatal("result never returned")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -433,45 +693,35 @@ func TestOAuthCallbackUnknownSessionIgnored(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestOAuthResultConsumedSecondCallIsGone(t *testing.T) {
|
|
||||||
h := newOAuthHarness(t)
|
|
||||||
sess, _ := h.startSession(t, "mal", "3.3.3.1")
|
|
||||||
|
|
||||||
// Pre-seat the result so the first /auth/result returns immediately.
|
|
||||||
h.proxy.mu.Lock()
|
|
||||||
h.proxy.sessions[sess].complete(oauthTokenResult{AccessToken: "tok"})
|
|
||||||
h.proxy.mu.Unlock()
|
|
||||||
|
|
||||||
r1 := httpGet(t, h.base+"/auth/result?session="+url.QueryEscape(sess))
|
|
||||||
r1.Body.Close()
|
|
||||||
if r1.StatusCode != http.StatusOK {
|
|
||||||
t.Fatalf("first result status=%d", r1.StatusCode)
|
|
||||||
}
|
|
||||||
// After consumption the session is deleted; second call sees unknown session.
|
|
||||||
r2 := httpGet(t, h.base+"/auth/result?session="+url.QueryEscape(sess))
|
|
||||||
r2.Body.Close()
|
|
||||||
if r2.StatusCode != http.StatusGone {
|
|
||||||
t.Fatalf("second result status=%d want 410", r2.StatusCode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ====== Cleanup ======
|
// ====== Cleanup ======
|
||||||
|
|
||||||
func TestOAuthCleanupExpiresOldSessions(t *testing.T) {
|
func TestOAuthCleanupRemovesBothIndexesAndSuppressesStaleCompletion(t *testing.T) {
|
||||||
h := newOAuthHarness(t)
|
h := newOAuthHarness(t)
|
||||||
sess, _ := h.startSession(t, "mal", "4.4.4.1")
|
pollSecret, browserState, _ := h.startSession(t, "mal", "4.4.4.1")
|
||||||
|
digest := digestPollSecret(pollSecret)
|
||||||
|
|
||||||
h.proxy.mu.Lock()
|
h.proxy.mu.Lock()
|
||||||
h.proxy.sessions[sess].createdAt = time.Now().Add(-2 * oauthSessionTTL)
|
sess := h.proxy.browserStates[browserState]
|
||||||
|
sess.createdAt = time.Now().Add(-2 * oauthSessionTTL)
|
||||||
h.proxy.mu.Unlock()
|
h.proxy.mu.Unlock()
|
||||||
|
|
||||||
h.proxy.cleanup()
|
h.proxy.cleanup()
|
||||||
|
|
||||||
h.proxy.mu.Lock()
|
h.proxy.mu.Lock()
|
||||||
_, exists := h.proxy.sessions[sess]
|
_, hasBrowserState := h.proxy.browserStates[browserState]
|
||||||
|
_, hasPollDigest := h.proxy.pollDigests[digest]
|
||||||
h.proxy.mu.Unlock()
|
h.proxy.mu.Unlock()
|
||||||
if exists {
|
if hasBrowserState || hasPollDigest {
|
||||||
t.Fatal("expired session should have been cleaned up")
|
t.Fatalf("expired session indexes remain: browser=%v poll=%v", hasBrowserState, hasPollDigest)
|
||||||
|
}
|
||||||
|
if h.proxy.completeSession(sess, oauthTokenResult{AccessToken: "stale-token"}) {
|
||||||
|
t.Fatal("stale callback completed a removed session")
|
||||||
|
}
|
||||||
|
sess.mu.Lock()
|
||||||
|
storedResult := sess.result
|
||||||
|
sess.mu.Unlock()
|
||||||
|
if storedResult != nil {
|
||||||
|
t.Fatal("stale callback retained an orphaned token result")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -525,7 +775,7 @@ func TestOAuthResultBlocksUntilCancel(t *testing.T) {
|
|||||||
// that /auth/result blocks until the session completes or the client
|
// that /auth/result blocks until the session completes or the client
|
||||||
// cancels. The 204-after-server-timeout path takes 50s so isn't asserted.
|
// cancels. The 204-after-server-timeout path takes 50s so isn't asserted.
|
||||||
h := newOAuthHarness(t)
|
h := newOAuthHarness(t)
|
||||||
sess, _ := h.startSession(t, "mal", "5.5.5.1")
|
sess, _, _ := h.startSession(t, "mal", "5.5.5.1")
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|||||||
+113
-10
@@ -16,27 +16,27 @@ type rateLimiter struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newRateLimiter(burst, sustained int) *rateLimiter {
|
func newRateLimiter(burst, sustained int) *rateLimiter {
|
||||||
|
return newRateLimiterAt(burst, sustained, time.Now())
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRateLimiterAt(burst, sustained int, now time.Time) *rateLimiter {
|
||||||
return &rateLimiter{
|
return &rateLimiter{
|
||||||
tokens: float64(burst),
|
tokens: float64(burst),
|
||||||
maxTokens: float64(burst),
|
maxTokens: float64(burst),
|
||||||
refillRate: float64(sustained),
|
refillRate: float64(sustained),
|
||||||
lastTime: time.Now(),
|
lastTime: now,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rl *rateLimiter) allow() bool {
|
func (rl *rateLimiter) allow() bool {
|
||||||
|
return rl.allowAt(time.Now())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rl *rateLimiter) allowAt(now time.Time) bool {
|
||||||
rl.mu.Lock()
|
rl.mu.Lock()
|
||||||
defer rl.mu.Unlock()
|
defer rl.mu.Unlock()
|
||||||
|
|
||||||
now := time.Now()
|
rl.refillAtLocked(now)
|
||||||
elapsed := now.Sub(rl.lastTime).Seconds()
|
|
||||||
rl.lastTime = now
|
|
||||||
|
|
||||||
rl.tokens += elapsed * rl.refillRate
|
|
||||||
if rl.tokens > rl.maxTokens {
|
|
||||||
rl.tokens = rl.maxTokens
|
|
||||||
}
|
|
||||||
|
|
||||||
if rl.tokens < 1 {
|
if rl.tokens < 1 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -44,6 +44,27 @@ func (rl *rateLimiter) allow() bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (rl *rateLimiter) refund() {
|
||||||
|
rl.mu.Lock()
|
||||||
|
defer rl.mu.Unlock()
|
||||||
|
rl.tokens++
|
||||||
|
if rl.tokens > rl.maxTokens {
|
||||||
|
rl.tokens = rl.maxTokens
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rl *rateLimiter) refillAtLocked(now time.Time) {
|
||||||
|
if now.Before(rl.lastTime) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
elapsed := now.Sub(rl.lastTime).Seconds()
|
||||||
|
rl.lastTime = now
|
||||||
|
rl.tokens += elapsed * rl.refillRate
|
||||||
|
if rl.tokens > rl.maxTokens {
|
||||||
|
rl.tokens = rl.maxTokens
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// reclaimable reports whether discarding this limiter would preserve its
|
// reclaimable reports whether discarding this limiter would preserve its
|
||||||
// behavior: enough idle time has passed for the bucket to be full again.
|
// behavior: enough idle time has passed for the bucket to be full again.
|
||||||
func (rl *rateLimiter) reclaimable(now time.Time) bool {
|
func (rl *rateLimiter) reclaimable(now time.Time) bool {
|
||||||
@@ -69,6 +90,68 @@ func cleanupRateWindows(windows map[string]time.Time, now time.Time, duration ti
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Poster upload admission (global, per-IP, and concurrency) ---
|
||||||
|
|
||||||
|
type posterUploadLimiter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
global *rateLimiter
|
||||||
|
perIP map[string]*rateLimiter
|
||||||
|
active int
|
||||||
|
maxConcurrent int
|
||||||
|
perIPBurst int
|
||||||
|
perIPSustained int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPosterUploadLimiter(
|
||||||
|
perIPBurst, perIPSustained, globalBurst, globalSustained, maxConcurrent int,
|
||||||
|
now time.Time,
|
||||||
|
) *posterUploadLimiter {
|
||||||
|
return &posterUploadLimiter{
|
||||||
|
global: newRateLimiterAt(globalBurst, globalSustained, now),
|
||||||
|
perIP: make(map[string]*rateLimiter),
|
||||||
|
maxConcurrent: maxConcurrent,
|
||||||
|
perIPBurst: perIPBurst,
|
||||||
|
perIPSustained: perIPSustained,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pl *posterUploadLimiter) tryStart(ip string, now time.Time) bool {
|
||||||
|
pl.mu.Lock()
|
||||||
|
defer pl.mu.Unlock()
|
||||||
|
|
||||||
|
if pl.active >= pl.maxConcurrent {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !pl.global.allowAt(now) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
limiter := pl.perIP[ip]
|
||||||
|
if limiter == nil {
|
||||||
|
limiter = newRateLimiterAt(pl.perIPBurst, pl.perIPSustained, now)
|
||||||
|
pl.perIP[ip] = limiter
|
||||||
|
}
|
||||||
|
if !limiter.allowAt(now) {
|
||||||
|
pl.global.refund()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
pl.active++
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pl *posterUploadLimiter) finish() {
|
||||||
|
pl.mu.Lock()
|
||||||
|
defer pl.mu.Unlock()
|
||||||
|
if pl.active > 0 {
|
||||||
|
pl.active--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pl *posterUploadLimiter) cleanup(now time.Time) {
|
||||||
|
pl.mu.Lock()
|
||||||
|
defer pl.mu.Unlock()
|
||||||
|
cleanupRateLimiters(pl.perIP, now, nil)
|
||||||
|
}
|
||||||
|
|
||||||
// --- Connection tracker (per-IP limits) ---
|
// --- Connection tracker (per-IP limits) ---
|
||||||
|
|
||||||
type connTracker struct {
|
type connTracker struct {
|
||||||
@@ -127,6 +210,9 @@ func (ct *connTracker) disconnect(ip string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tryCreateRoom reserves capacity for a retained room created in this process.
|
||||||
|
// The reservation survives creator disconnect and is released only when the
|
||||||
|
// authoritative room is removed from Server.rooms.
|
||||||
func (ct *connTracker) tryCreateRoom(ip string) bool {
|
func (ct *connTracker) tryCreateRoom(ip string) bool {
|
||||||
ct.mu.Lock()
|
ct.mu.Lock()
|
||||||
defer ct.mu.Unlock()
|
defer ct.mu.Unlock()
|
||||||
@@ -137,6 +223,23 @@ func (ct *connTracker) tryCreateRoom(ip string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tryCreateRoomReplacing reserves a room while accounting for the reservation
|
||||||
|
// that removeRoomLocked will immediately release from an empty same-ID room.
|
||||||
|
// Server.mu serializes this paired reservation/removal transaction.
|
||||||
|
func (ct *connTracker) tryCreateRoomReplacing(ip, replacedOwnerKey string) bool {
|
||||||
|
ct.mu.Lock()
|
||||||
|
defer ct.mu.Unlock()
|
||||||
|
projected := ct.roomsPerIP[ip]
|
||||||
|
if replacedOwnerKey == ip {
|
||||||
|
projected--
|
||||||
|
}
|
||||||
|
if projected >= maxRoomsPerIP {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
ct.roomsPerIP[ip]++
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func (ct *connTracker) releaseRoom(ip string) {
|
func (ct *connTracker) releaseRoom(ip string) {
|
||||||
ct.mu.Lock()
|
ct.mu.Lock()
|
||||||
defer ct.mu.Unlock()
|
defer ct.mu.Unlock()
|
||||||
|
|||||||
@@ -3,25 +3,34 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
const (
|
const (
|
||||||
relayTypeCreate = "create"
|
relayProtocolVersion = 2
|
||||||
relayTypeJoin = "join"
|
legacyRelayProtocolVersion = 0
|
||||||
relayTypeBroadcast = "broadcast"
|
|
||||||
relayTypeSendTo = "sendTo"
|
relayTypeCreate = "create"
|
||||||
relayTypePing = "ping"
|
relayTypeJoin = "join"
|
||||||
relayTypeCreated = "created"
|
relayTypeBroadcast = "broadcast"
|
||||||
relayTypeJoined = "joined"
|
relayTypeSendTo = "sendTo"
|
||||||
relayTypePeerJoined = "peerJoined"
|
relayTypePing = "ping"
|
||||||
relayTypePeerLeft = "peerLeft"
|
relayTypeLeave = "leave"
|
||||||
relayTypeMessage = "message"
|
relayTypeEndSession = "endSession"
|
||||||
relayTypeError = "error"
|
relayTypeCreated = "created"
|
||||||
relayTypePong = "pong"
|
relayTypeJoined = "joined"
|
||||||
relayErrorRateLimited = "rate_limited"
|
relayTypePeerJoined = "peerJoined"
|
||||||
relayErrorInvalidMessage = "invalid_message"
|
relayTypePeerLeft = "peerLeft"
|
||||||
relayErrorRoomExists = "room_exists"
|
relayTypeMessage = "message"
|
||||||
relayErrorRoomNotFound = "room_not_found"
|
relayTypeError = "error"
|
||||||
relayErrorRoomFull = "room_full"
|
relayTypePong = "pong"
|
||||||
relayErrorNotInRoom = "not_in_room"
|
relayTypeLeft = "left"
|
||||||
relayErrorAlreadyInRoom = "already_in_room"
|
relayTypeEnded = "ended"
|
||||||
|
relayErrorRateLimited = "rate_limited"
|
||||||
|
relayErrorInvalidMessage = "invalid_message"
|
||||||
|
relayErrorRoomExists = "room_exists"
|
||||||
|
relayErrorRoomNotFound = "room_not_found"
|
||||||
|
relayErrorRoomFull = "room_full"
|
||||||
|
relayErrorNotInRoom = "not_in_room"
|
||||||
|
relayErrorAlreadyInRoom = "already_in_room"
|
||||||
|
relayErrorPeerIdUnavailable = "peer_id_unavailable"
|
||||||
|
relayErrorProtocolMismatch = "protocol_mismatch"
|
||||||
|
|
||||||
maxRoomSize = 8
|
maxRoomSize = 8
|
||||||
maxMessageSize = 65536
|
maxMessageSize = 65536
|
||||||
|
|||||||
@@ -34,12 +34,25 @@ void main() {
|
|||||||
|
|
||||||
test('RecentRoom preserves epoch timestamp and control mode index', () {
|
test('RecentRoom preserves epoch timestamp and control mode index', () {
|
||||||
final lastUsed = DateTime.fromMillisecondsSinceEpoch(1700000000000);
|
final lastUsed = DateTime.fromMillisecondsSinceEpoch(1700000000000);
|
||||||
final room = RecentRoom(code: 'ABCD', name: 'Movie night', lastUsed: lastUsed, controlMode: ControlMode.anyone);
|
final room = RecentRoom(
|
||||||
|
code: 'ABCD',
|
||||||
|
relayScope: 'scope-digest',
|
||||||
|
name: 'Movie night',
|
||||||
|
lastUsed: lastUsed,
|
||||||
|
controlMode: ControlMode.anyone,
|
||||||
|
);
|
||||||
|
|
||||||
expect(room.toJson(), {'code': 'ABCD', 'name': 'Movie night', 'lastUsed': 1700000000000, 'controlMode': 1});
|
expect(room.toJson(), {
|
||||||
|
'code': 'ABCD',
|
||||||
|
'relayScope': 'scope-digest',
|
||||||
|
'name': 'Movie night',
|
||||||
|
'lastUsed': 1700000000000,
|
||||||
|
'controlMode': 1,
|
||||||
|
});
|
||||||
|
|
||||||
final decoded = RecentRoom.fromJson(room.toJson());
|
final decoded = RecentRoom.fromJson(room.toJson());
|
||||||
expect(decoded.code, room.code);
|
expect(decoded.code, room.code);
|
||||||
|
expect(decoded.relayScope, room.relayScope);
|
||||||
expect(decoded.name, room.name);
|
expect(decoded.name, room.name);
|
||||||
expect(decoded.lastUsed, lastUsed);
|
expect(decoded.lastUsed, lastUsed);
|
||||||
expect(decoded.controlMode, ControlMode.anyone);
|
expect(decoded.controlMode, ControlMode.anyone);
|
||||||
@@ -47,9 +60,13 @@ void main() {
|
|||||||
|
|
||||||
test('RecentRoom omits nullable fields when absent', () {
|
test('RecentRoom omits nullable fields when absent', () {
|
||||||
final lastUsed = DateTime.fromMillisecondsSinceEpoch(1700000000000);
|
final lastUsed = DateTime.fromMillisecondsSinceEpoch(1700000000000);
|
||||||
final room = RecentRoom(code: 'ABCD', lastUsed: lastUsed);
|
final room = RecentRoom(code: 'ABCD', relayScope: 'scope-digest', lastUsed: lastUsed);
|
||||||
|
|
||||||
expect(room.toJson(), {'code': 'ABCD', 'lastUsed': 1700000000000});
|
expect(room.toJson(), {'code': 'ABCD', 'relayScope': 'scope-digest', 'lastUsed': 1700000000000});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('RecentRoom requires relay scope when decoding', () {
|
||||||
|
expect(() => RecentRoom.fromJson({'code': 'ABCD', 'lastUsed': 1700000000000}), throwsA(anything));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('RemoteCommand keeps compact protocol keys and unknown fallback', () {
|
test('RemoteCommand keeps compact protocol keys and unknown fallback', () {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import 'package:plezy/profiles/plex_home_service.dart';
|
|||||||
import 'package:plezy/profiles/profile.dart';
|
import 'package:plezy/profiles/profile.dart';
|
||||||
import 'package:plezy/profiles/profile_connection_registry.dart';
|
import 'package:plezy/profiles/profile_connection_registry.dart';
|
||||||
import 'package:plezy/profiles/profile_registry.dart';
|
import 'package:plezy/profiles/profile_registry.dart';
|
||||||
|
import 'package:plezy/providers/companion_remote_provider.dart';
|
||||||
import 'package:plezy/providers/discover_provider.dart';
|
import 'package:plezy/providers/discover_provider.dart';
|
||||||
import 'package:plezy/providers/hidden_libraries_provider.dart';
|
import 'package:plezy/providers/hidden_libraries_provider.dart';
|
||||||
import 'package:plezy/providers/multi_server_provider.dart';
|
import 'package:plezy/providers/multi_server_provider.dart';
|
||||||
@@ -57,11 +58,12 @@ void main() {
|
|||||||
final discoverProviders = <DiscoverProvider>[];
|
final discoverProviders = <DiscoverProvider>[];
|
||||||
final hiddenProviders = <HiddenLibrariesProvider>[];
|
final hiddenProviders = <HiddenLibrariesProvider>[];
|
||||||
final trackerProviders = <TrackersProvider>[];
|
final trackerProviders = <TrackersProvider>[];
|
||||||
|
final companionProviders = <CompanionRemoteProvider>[];
|
||||||
final disposedActiveIds = <String>[];
|
final disposedActiveIds = <String>[];
|
||||||
|
|
||||||
addTearDown(() async {
|
addTearDown(() async {
|
||||||
await tester.pumpWidget(const SizedBox.shrink());
|
await tester.pumpWidget(const SizedBox.shrink());
|
||||||
await tester.pump();
|
await tester.pumpAndSettle();
|
||||||
await activeProfile.resetForTesting();
|
await activeProfile.resetForTesting();
|
||||||
activeProfile.dispose();
|
activeProfile.dispose();
|
||||||
multiServer.dispose();
|
multiServer.dispose();
|
||||||
@@ -85,6 +87,9 @@ void main() {
|
|||||||
providers: [
|
providers: [
|
||||||
Provider<StorageService>.value(value: storage),
|
Provider<StorageService>.value(value: storage),
|
||||||
Provider<AppDatabase>.value(value: db),
|
Provider<AppDatabase>.value(value: db),
|
||||||
|
Provider<ConnectionRegistry>.value(value: connectionRegistry),
|
||||||
|
Provider<ProfileConnectionRegistry>.value(value: profileConnectionRegistry),
|
||||||
|
Provider<PlexHomeService>.value(value: plexHome),
|
||||||
ChangeNotifierProvider<ActiveProfileProvider>.value(value: activeProfile),
|
ChangeNotifierProvider<ActiveProfileProvider>.value(value: activeProfile),
|
||||||
ChangeNotifierProvider<MultiServerProvider>.value(value: multiServer),
|
ChangeNotifierProvider<MultiServerProvider>.value(value: multiServer),
|
||||||
ChangeNotifierProvider<OfflineWatchSyncService>.value(value: offlineWatch),
|
ChangeNotifierProvider<OfflineWatchSyncService>.value(value: offlineWatch),
|
||||||
@@ -96,6 +101,7 @@ void main() {
|
|||||||
discoverProviders: discoverProviders,
|
discoverProviders: discoverProviders,
|
||||||
hiddenProviders: hiddenProviders,
|
hiddenProviders: hiddenProviders,
|
||||||
trackerProviders: trackerProviders,
|
trackerProviders: trackerProviders,
|
||||||
|
companionProviders: companionProviders,
|
||||||
disposedActiveIds: disposedActiveIds,
|
disposedActiveIds: disposedActiveIds,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -109,10 +115,12 @@ void main() {
|
|||||||
expect(discoverProviders.single.profileId, owner.id);
|
expect(discoverProviders.single.profileId, owner.id);
|
||||||
expect(discoverProviders, hasLength(1));
|
expect(discoverProviders, hasLength(1));
|
||||||
expect(hiddenProviders, hasLength(1));
|
expect(hiddenProviders, hasLength(1));
|
||||||
|
expect(companionProviders, hasLength(1));
|
||||||
final ownerNavigator = profileNavigationRegistry.navigator;
|
final ownerNavigator = profileNavigationRegistry.navigator;
|
||||||
final ownerDiscover = discoverProviders.single;
|
final ownerDiscover = discoverProviders.single;
|
||||||
final ownerHidden = hiddenProviders.single;
|
final ownerHidden = hiddenProviders.single;
|
||||||
final ownerTrackers = trackerProviders.single;
|
final ownerTrackers = trackerProviders.single;
|
||||||
|
final ownerCompanion = companionProviders.single;
|
||||||
await ownerHidden.ensureInitialized();
|
await ownerHidden.ensureInitialized();
|
||||||
expect(ownerHidden.profileId, owner.id);
|
expect(ownerHidden.profileId, owner.id);
|
||||||
expect(ownerHidden.hiddenLibraryKeys, {'srv:owner'});
|
expect(ownerHidden.hiddenLibraryKeys, {'srv:owner'});
|
||||||
@@ -134,6 +142,9 @@ void main() {
|
|||||||
expect(trackerProviders, hasLength(2));
|
expect(trackerProviders, hasLength(2));
|
||||||
expect(trackerProviders.last, isNot(same(ownerTrackers)));
|
expect(trackerProviders.last, isNot(same(ownerTrackers)));
|
||||||
expect(ownerTrackers.isDisposed, isTrue);
|
expect(ownerTrackers.isDisposed, isTrue);
|
||||||
|
expect(companionProviders, hasLength(2));
|
||||||
|
expect(companionProviders.last, isNot(same(ownerCompanion)));
|
||||||
|
expect(ownerCompanion.isDisposed, isTrue);
|
||||||
await hiddenProviders.last.ensureInitialized();
|
await hiddenProviders.last.ensureInitialized();
|
||||||
expect(hiddenProviders.last.profileId, kids.id);
|
expect(hiddenProviders.last.profileId, kids.id);
|
||||||
expect(hiddenProviders.last.hiddenLibraryKeys, {'srv:kids'});
|
expect(hiddenProviders.last.hiddenLibraryKeys, {'srv:kids'});
|
||||||
@@ -145,17 +156,27 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(SystemShelfService().debugActiveOwner, isNull);
|
expect(SystemShelfService().debugActiveOwner, isNull);
|
||||||
expect(discoverProviders.last.profileId, isNull);
|
expect(discoverProviders.last.profileId, isNull);
|
||||||
|
|
||||||
|
// Companion provider disposal cancels Drift-backed profile watches. Give
|
||||||
|
// their asynchronous cancellation timers a frame before test invariants
|
||||||
|
// are checked.
|
||||||
|
await tester.pumpWidget(const SizedBox.shrink());
|
||||||
|
for (var i = 0; i < 3; i++) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 1));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ProfileProbeShell extends StatefulWidget {
|
class _ProfileProbeShell extends StatefulWidget {
|
||||||
const _ProfileProbeShell({
|
const _ProfileProbeShell({
|
||||||
required this.discoverProviders,
|
required this.discoverProviders,
|
||||||
|
required this.companionProviders,
|
||||||
required this.hiddenProviders,
|
required this.hiddenProviders,
|
||||||
required this.disposedActiveIds,
|
required this.disposedActiveIds,
|
||||||
required this.trackerProviders,
|
required this.trackerProviders,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final List<CompanionRemoteProvider> companionProviders;
|
||||||
final List<DiscoverProvider> discoverProviders;
|
final List<DiscoverProvider> discoverProviders;
|
||||||
final List<HiddenLibrariesProvider> hiddenProviders;
|
final List<HiddenLibrariesProvider> hiddenProviders;
|
||||||
final List<TrackersProvider> trackerProviders;
|
final List<TrackersProvider> trackerProviders;
|
||||||
@@ -166,6 +187,7 @@ class _ProfileProbeShell extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _ProfileProbeShellState extends State<_ProfileProbeShell> {
|
class _ProfileProbeShellState extends State<_ProfileProbeShell> {
|
||||||
|
CompanionRemoteProvider? _companionProvider;
|
||||||
DiscoverProvider? _discoverProvider;
|
DiscoverProvider? _discoverProvider;
|
||||||
HiddenLibrariesProvider? _hiddenProvider;
|
HiddenLibrariesProvider? _hiddenProvider;
|
||||||
TrackersProvider? _trackersProvider;
|
TrackersProvider? _trackersProvider;
|
||||||
@@ -174,6 +196,7 @@ class _ProfileProbeShellState extends State<_ProfileProbeShell> {
|
|||||||
@override
|
@override
|
||||||
void didChangeDependencies() {
|
void didChangeDependencies() {
|
||||||
super.didChangeDependencies();
|
super.didChangeDependencies();
|
||||||
|
_companionProvider = context.read<CompanionRemoteProvider>();
|
||||||
_discoverProvider = context.read<DiscoverProvider>();
|
_discoverProvider = context.read<DiscoverProvider>();
|
||||||
_hiddenProvider = context.read<HiddenLibrariesProvider>();
|
_hiddenProvider = context.read<HiddenLibrariesProvider>();
|
||||||
_trackersProvider = context.read<TrackersProvider>();
|
_trackersProvider = context.read<TrackersProvider>();
|
||||||
@@ -187,6 +210,9 @@ class _ProfileProbeShellState extends State<_ProfileProbeShell> {
|
|||||||
if (widget.trackerProviders.isEmpty || !identical(widget.trackerProviders.last, _trackersProvider)) {
|
if (widget.trackerProviders.isEmpty || !identical(widget.trackerProviders.last, _trackersProvider)) {
|
||||||
widget.trackerProviders.add(_trackersProvider!);
|
widget.trackerProviders.add(_trackersProvider!);
|
||||||
}
|
}
|
||||||
|
if (widget.companionProviders.isEmpty || !identical(widget.companionProviders.last, _companionProvider)) {
|
||||||
|
widget.companionProviders.add(_companionProvider!);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:drift/native.dart';
|
import 'package:drift/native.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:plezy/connection/connection.dart';
|
import 'package:plezy/connection/connection.dart';
|
||||||
@@ -15,7 +17,9 @@ import 'package:plezy/profiles/profile_connection.dart';
|
|||||||
import 'package:plezy/profiles/profile_connection_registry.dart';
|
import 'package:plezy/profiles/profile_connection_registry.dart';
|
||||||
import 'package:plezy/profiles/profile_registry.dart';
|
import 'package:plezy/profiles/profile_registry.dart';
|
||||||
import 'package:plezy/providers/companion_remote_provider.dart';
|
import 'package:plezy/providers/companion_remote_provider.dart';
|
||||||
import 'package:plezy/services/base_peer_service.dart';
|
import 'package:plezy/services/companion_remote/companion_remote_peer_service.dart';
|
||||||
|
import 'package:plezy/services/companion_remote/lan_discovery_service.dart';
|
||||||
|
import 'package:plezy/services/companion_remote/remote_auth_context.dart';
|
||||||
import 'package:plezy/services/companion_remote/remote_auth_service.dart';
|
import 'package:plezy/services/companion_remote/remote_auth_service.dart';
|
||||||
import 'package:plezy/services/storage_service.dart';
|
import 'package:plezy/services/storage_service.dart';
|
||||||
|
|
||||||
@@ -121,6 +125,325 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('CompanionRemoteProvider — reconnect ownership', () {
|
||||||
|
test('newest overlapping connect owns the session and disposes the stale candidate once', () async {
|
||||||
|
final firstJoinGate = Completer<void>();
|
||||||
|
final firstDisposalGate = Completer<void>();
|
||||||
|
final first = _FakeCompanionRemotePeerService(joinGate: firstJoinGate, disconnectGate: firstDisposalGate);
|
||||||
|
final second = _FakeCompanionRemotePeerService();
|
||||||
|
final factory = _FakePeerFactory([first, second]);
|
||||||
|
final harness = await _RemoteHarness.create(factory.call);
|
||||||
|
addTearDown(harness.close);
|
||||||
|
|
||||||
|
final olderConnect = harness.provider.connectToManualHost('192.0.2.20:48634');
|
||||||
|
await first.joinStarted.future;
|
||||||
|
final newerConnect = harness.provider.connectToManualHost('192.0.2.21:48634');
|
||||||
|
await first.disconnectStarted.future;
|
||||||
|
firstJoinGate.complete();
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
expect(first.disposeCalls, 1);
|
||||||
|
|
||||||
|
firstDisposalGate.complete();
|
||||||
|
await second.joinStarted.future;
|
||||||
|
await newerConnect;
|
||||||
|
|
||||||
|
harness.provider.sendCommand(RemoteCommandType.playPause);
|
||||||
|
expect(harness.provider.status, RemoteSessionStatus.connected);
|
||||||
|
expect(second.sentCommands.single.type, RemoteCommandType.playPause);
|
||||||
|
|
||||||
|
await olderConnect;
|
||||||
|
|
||||||
|
expect(harness.provider.status, RemoteSessionStatus.connected);
|
||||||
|
expect(first.disposeCalls, 1);
|
||||||
|
expect(first.disconnectCalls, 1);
|
||||||
|
expect(first.hasListeners, isFalse);
|
||||||
|
expect(second.disposeCalls, 0);
|
||||||
|
expect(factory.created, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('replacement disconnect reconnects while intentional predecessor teardown is blocked', () async {
|
||||||
|
final predecessorDisposalGate = Completer<void>();
|
||||||
|
final predecessor = _FakeCompanionRemotePeerService(disconnectGate: predecessorDisposalGate);
|
||||||
|
final replacement = _FakeCompanionRemotePeerService();
|
||||||
|
final factory = _FakePeerFactory([predecessor, replacement]);
|
||||||
|
final harness = await _RemoteHarness.create(factory.call);
|
||||||
|
addTearDown(harness.close);
|
||||||
|
|
||||||
|
Future<void>? predecessorTeardown;
|
||||||
|
addTearDown(() async {
|
||||||
|
if (!predecessorDisposalGate.isCompleted) {
|
||||||
|
predecessorDisposalGate.complete();
|
||||||
|
}
|
||||||
|
final teardown = predecessorTeardown;
|
||||||
|
if (teardown != null) await teardown;
|
||||||
|
});
|
||||||
|
|
||||||
|
await harness.provider.connectToManualHost('192.0.2.22:48634');
|
||||||
|
|
||||||
|
var teardownCompleted = false;
|
||||||
|
predecessorTeardown = harness.provider.leaveSession().whenComplete(() {
|
||||||
|
teardownCompleted = true;
|
||||||
|
});
|
||||||
|
await predecessor.disconnectStarted.future;
|
||||||
|
expect(teardownCompleted, isFalse);
|
||||||
|
|
||||||
|
await harness.provider.connectToManualHost('192.0.2.23:48634');
|
||||||
|
expect(harness.provider.status, RemoteSessionStatus.connected);
|
||||||
|
expect(replacement.hasListeners, isTrue);
|
||||||
|
|
||||||
|
final publishedStatuses = <RemoteSessionStatus>[];
|
||||||
|
void captureStatus() => publishedStatuses.add(harness.provider.status);
|
||||||
|
harness.provider.addListener(captureStatus);
|
||||||
|
|
||||||
|
predecessor.emitError(
|
||||||
|
RemotePeerError(type: RemotePeerErrorType.connectionFailed, message: 'stale predecessor error'),
|
||||||
|
);
|
||||||
|
predecessor.emitDeviceDisconnected();
|
||||||
|
|
||||||
|
expect(publishedStatuses, isEmpty);
|
||||||
|
expect(harness.provider.status, RemoteSessionStatus.connected);
|
||||||
|
expect(harness.provider.session?.errorMessage, isNull);
|
||||||
|
expect(harness.provider.reconnectAttempts, 0);
|
||||||
|
|
||||||
|
replacement.emitDeviceDisconnected();
|
||||||
|
|
||||||
|
expect(publishedStatuses, [RemoteSessionStatus.reconnecting]);
|
||||||
|
expect(harness.provider.status, RemoteSessionStatus.reconnecting);
|
||||||
|
expect(harness.provider.reconnectAttempts, 1);
|
||||||
|
expect(teardownCompleted, isFalse);
|
||||||
|
|
||||||
|
predecessorDisposalGate.complete();
|
||||||
|
await predecessorTeardown;
|
||||||
|
|
||||||
|
expect(publishedStatuses, [RemoteSessionStatus.reconnecting]);
|
||||||
|
expect(harness.provider.status, RemoteSessionStatus.reconnecting);
|
||||||
|
expect(harness.provider.reconnectAttempts, 1);
|
||||||
|
|
||||||
|
harness.provider.removeListener(captureStatus);
|
||||||
|
await harness.provider.cancelReconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cancelReconnect fully tears down a running host and discovery', () async {
|
||||||
|
final hostDisposalGate = Completer<void>();
|
||||||
|
final host = _FakeCompanionRemotePeerService(disconnectGate: hostDisposalGate);
|
||||||
|
final discovery = _FakeLanDiscoveryService();
|
||||||
|
final factory = _FakePeerFactory([host]);
|
||||||
|
final harness = await _RemoteHarness.create(factory.call, discoveryServiceFactory: () => discovery);
|
||||||
|
addTearDown(harness.close);
|
||||||
|
|
||||||
|
await harness.provider.startHostServer();
|
||||||
|
expect(harness.provider.isHostServerRunning, isTrue);
|
||||||
|
expect(harness.provider.debugIsDiscoveryBroadcasting, isTrue);
|
||||||
|
expect(host.hasListeners, isTrue);
|
||||||
|
|
||||||
|
final cancellation = harness.provider.cancelReconnect();
|
||||||
|
await host.disconnectStarted.future;
|
||||||
|
expect(harness.provider.debugIsDiscoveryBroadcasting, isFalse);
|
||||||
|
expect(host.hasListeners, isFalse);
|
||||||
|
hostDisposalGate.complete();
|
||||||
|
await cancellation;
|
||||||
|
|
||||||
|
expect(harness.provider.session, isNull);
|
||||||
|
expect(harness.provider.isHostServerRunning, isFalse);
|
||||||
|
expect(harness.provider.debugIsDiscoveryBroadcasting, isFalse);
|
||||||
|
expect(harness.provider.debugIsDiscoveryListening, isFalse);
|
||||||
|
expect(discovery.stopBroadcastingCalls, 1);
|
||||||
|
expect(discovery.stopListeningCalls, 1);
|
||||||
|
expect(host.disposeCalls, 1);
|
||||||
|
expect(host.disconnectCalls, 1);
|
||||||
|
expect(host.hasListeners, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cancel during join keeps disconnected state and disposes the candidate once', () async {
|
||||||
|
final initial = _FakeCompanionRemotePeerService();
|
||||||
|
final joinGate = Completer<void>();
|
||||||
|
final candidate = _FakeCompanionRemotePeerService(joinGate: joinGate);
|
||||||
|
final factory = _FakePeerFactory([initial, candidate]);
|
||||||
|
final harness = await _RemoteHarness.create(factory.call);
|
||||||
|
addTearDown(harness.close);
|
||||||
|
await harness.provider.connectToManualHost('192.0.2.10:48634');
|
||||||
|
|
||||||
|
final statuses = <RemoteSessionStatus>[];
|
||||||
|
harness.provider.addListener(() => statuses.add(harness.provider.status));
|
||||||
|
initial.emitDeviceDisconnected();
|
||||||
|
expect(harness.provider.status, RemoteSessionStatus.reconnecting);
|
||||||
|
|
||||||
|
final retry = harness.provider.retryReconnectNow();
|
||||||
|
await candidate.joinStarted.future;
|
||||||
|
expect(candidate.hasListeners, isTrue);
|
||||||
|
|
||||||
|
final cancellation = harness.provider.cancelReconnect();
|
||||||
|
expect(harness.provider.status, RemoteSessionStatus.disconnected);
|
||||||
|
await cancellation;
|
||||||
|
final statusCountAfterCancel = statuses.length;
|
||||||
|
|
||||||
|
joinGate.complete();
|
||||||
|
await retry;
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
|
expect(harness.provider.status, RemoteSessionStatus.disconnected);
|
||||||
|
expect(statuses.skip(statusCountAfterCancel), isNot(contains(RemoteSessionStatus.connected)));
|
||||||
|
expect(candidate.disposeCalls, 1);
|
||||||
|
expect(candidate.disconnectCalls, 1);
|
||||||
|
expect(candidate.hasListeners, isFalse);
|
||||||
|
expect(factory.created, 2);
|
||||||
|
expect(harness.provider.reconnectAttempts, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('leave during join clears the session and rejects late commands', () async {
|
||||||
|
final initial = _FakeCompanionRemotePeerService();
|
||||||
|
final joinGate = Completer<void>();
|
||||||
|
final candidate = _FakeCompanionRemotePeerService(joinGate: joinGate);
|
||||||
|
final factory = _FakePeerFactory([initial, candidate]);
|
||||||
|
final harness = await _RemoteHarness.create(factory.call);
|
||||||
|
addTearDown(harness.close);
|
||||||
|
await harness.provider.connectToManualHost('192.0.2.11:48634');
|
||||||
|
|
||||||
|
var deliveredCommands = 0;
|
||||||
|
harness.provider.onCommandReceived = (_) => deliveredCommands++;
|
||||||
|
initial.emitDeviceDisconnected();
|
||||||
|
final retry = harness.provider.retryReconnectNow();
|
||||||
|
await candidate.joinStarted.future;
|
||||||
|
|
||||||
|
await harness.provider.leaveSession();
|
||||||
|
expect(harness.provider.session, isNull);
|
||||||
|
expect(candidate.hasListeners, isFalse);
|
||||||
|
candidate.emitCommand(const RemoteCommand(type: RemoteCommandType.playPause));
|
||||||
|
joinGate.complete();
|
||||||
|
await retry;
|
||||||
|
|
||||||
|
expect(harness.provider.session, isNull);
|
||||||
|
expect(deliveredCommands, 0);
|
||||||
|
expect(candidate.disposeCalls, 1);
|
||||||
|
expect(factory.created, 2);
|
||||||
|
expect(harness.provider.reconnectAttempts, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (final action in ['cancel', 'leave']) {
|
||||||
|
test('$action before candidate creation prevents replacement allocation', () async {
|
||||||
|
final disconnectGate = Completer<void>();
|
||||||
|
final initial = _FakeCompanionRemotePeerService(disconnectGate: disconnectGate);
|
||||||
|
final replacement = _FakeCompanionRemotePeerService();
|
||||||
|
final factory = _FakePeerFactory([initial, replacement]);
|
||||||
|
final harness = await _RemoteHarness.create(factory.call);
|
||||||
|
addTearDown(harness.close);
|
||||||
|
await harness.provider.connectToManualHost('192.0.2.12:48634');
|
||||||
|
|
||||||
|
initial.emitDeviceDisconnected();
|
||||||
|
final retry = harness.provider.retryReconnectNow();
|
||||||
|
await initial.disconnectStarted.future;
|
||||||
|
|
||||||
|
if (action == 'cancel') {
|
||||||
|
await harness.provider.cancelReconnect();
|
||||||
|
} else {
|
||||||
|
await harness.provider.leaveSession();
|
||||||
|
}
|
||||||
|
disconnectGate.complete();
|
||||||
|
await retry;
|
||||||
|
|
||||||
|
expect(factory.created, 1);
|
||||||
|
expect(replacement.joinStarted.isCompleted, isFalse);
|
||||||
|
expect(initial.disposeCalls, 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('dispose during join detaches listeners and prevents a late commit', () async {
|
||||||
|
final initial = _FakeCompanionRemotePeerService();
|
||||||
|
final joinGate = Completer<void>();
|
||||||
|
final candidate = _FakeCompanionRemotePeerService(joinGate: joinGate);
|
||||||
|
final factory = _FakePeerFactory([initial, candidate]);
|
||||||
|
final harness = await _RemoteHarness.create(factory.call);
|
||||||
|
addTearDown(harness.close);
|
||||||
|
await harness.provider.connectToManualHost('192.0.2.13:48634');
|
||||||
|
|
||||||
|
var deliveredCommands = 0;
|
||||||
|
harness.provider.onCommandReceived = (_) => deliveredCommands++;
|
||||||
|
initial.emitDeviceDisconnected();
|
||||||
|
final retry = harness.provider.retryReconnectNow();
|
||||||
|
await candidate.joinStarted.future;
|
||||||
|
|
||||||
|
harness.provider.dispose();
|
||||||
|
expect(harness.provider.isDisposed, isTrue);
|
||||||
|
expect(candidate.hasListeners, isFalse);
|
||||||
|
candidate.emitCommand(const RemoteCommand(type: RemoteCommandType.playPause));
|
||||||
|
joinGate.complete();
|
||||||
|
await retry;
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
|
expect(harness.provider.status, isNot(RemoteSessionStatus.connected));
|
||||||
|
expect(deliveredCommands, 0);
|
||||||
|
expect(candidate.disposeCalls, 1);
|
||||||
|
expect(factory.created, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('logout invalidates a held reconnect before clearing identity', () async {
|
||||||
|
final initial = _FakeCompanionRemotePeerService();
|
||||||
|
final joinGate = Completer<void>();
|
||||||
|
final candidate = _FakeCompanionRemotePeerService(joinGate: joinGate);
|
||||||
|
final factory = _FakePeerFactory([initial, candidate]);
|
||||||
|
final harness = await _RemoteHarness.create(factory.call);
|
||||||
|
addTearDown(harness.close);
|
||||||
|
await harness.provider.connectToManualHost('192.0.2.14:48634');
|
||||||
|
|
||||||
|
initial.emitDeviceDisconnected();
|
||||||
|
final retry = harness.provider.retryReconnectNow();
|
||||||
|
await candidate.joinStarted.future;
|
||||||
|
|
||||||
|
await harness.provider.resetForLogout();
|
||||||
|
expect(harness.provider.session, isNull);
|
||||||
|
expect(harness.provider.isCryptoReady, isFalse);
|
||||||
|
expect(harness.provider.debugCryptoConnectionId, isNull);
|
||||||
|
expect(candidate.hasListeners, isFalse);
|
||||||
|
|
||||||
|
joinGate.complete();
|
||||||
|
await retry;
|
||||||
|
|
||||||
|
expect(harness.provider.session, isNull);
|
||||||
|
expect(harness.provider.isCryptoReady, isFalse);
|
||||||
|
expect(candidate.disposeCalls, 1);
|
||||||
|
expect(factory.created, 2);
|
||||||
|
expect(harness.provider.reconnectAttempts, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('current reconnect failure is contained and schedules one retry', () async {
|
||||||
|
final initial = _FakeCompanionRemotePeerService();
|
||||||
|
final candidate = _FakeCompanionRemotePeerService(joinError: StateError('synthetic reconnect failure'));
|
||||||
|
final factory = _FakePeerFactory([initial, candidate]);
|
||||||
|
final harness = await _RemoteHarness.create(factory.call);
|
||||||
|
addTearDown(harness.close);
|
||||||
|
await harness.provider.connectToManualHost('192.0.2.15:48634');
|
||||||
|
|
||||||
|
initial.emitDeviceDisconnected();
|
||||||
|
await harness.provider.retryReconnectNow();
|
||||||
|
|
||||||
|
expect(harness.provider.status, RemoteSessionStatus.reconnecting);
|
||||||
|
expect(harness.provider.reconnectAttempts, 1);
|
||||||
|
expect(candidate.disposeCalls, 1);
|
||||||
|
expect(candidate.hasListeners, isFalse);
|
||||||
|
|
||||||
|
await harness.provider.cancelReconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('successful current reconnect preserves connected behavior', () async {
|
||||||
|
final initial = _FakeCompanionRemotePeerService();
|
||||||
|
final candidate = _FakeCompanionRemotePeerService();
|
||||||
|
final factory = _FakePeerFactory([initial, candidate]);
|
||||||
|
final harness = await _RemoteHarness.create(factory.call);
|
||||||
|
addTearDown(harness.close);
|
||||||
|
await harness.provider.connectToManualHost('192.0.2.16:48634');
|
||||||
|
|
||||||
|
initial.emitDeviceDisconnected();
|
||||||
|
await harness.provider.retryReconnectNow();
|
||||||
|
harness.provider.sendCommand(RemoteCommandType.playPause);
|
||||||
|
|
||||||
|
expect(harness.provider.status, RemoteSessionStatus.connected);
|
||||||
|
expect(harness.provider.reconnectAttempts, 0);
|
||||||
|
expect(candidate.sentCommands.single.type, RemoteCommandType.playPause);
|
||||||
|
expect(initial.disposeCalls, 1);
|
||||||
|
expect(candidate.disposeCalls, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
group('CompanionRemoteProvider — public API safety', () {
|
group('CompanionRemoteProvider — public API safety', () {
|
||||||
test('connectToDiscoveredHost reports localized auth failure when crypto is not ready', () async {
|
test('connectToDiscoveredHost reports localized auth failure when crypto is not ready', () async {
|
||||||
final p = CompanionRemoteProvider();
|
final p = CompanionRemoteProvider();
|
||||||
@@ -546,3 +869,264 @@ PlexHomeUser _homeUser(String uuid, {required bool admin}) {
|
|||||||
protected: false,
|
protected: false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _FakePeerFactory {
|
||||||
|
_FakePeerFactory(this.peers);
|
||||||
|
|
||||||
|
final List<_FakeCompanionRemotePeerService> peers;
|
||||||
|
int created = 0;
|
||||||
|
|
||||||
|
CompanionRemotePeerService call() {
|
||||||
|
if (created >= peers.length) {
|
||||||
|
throw StateError('Unexpected peer allocation');
|
||||||
|
}
|
||||||
|
return peers[created++];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FakeCompanionRemotePeerService extends CompanionRemotePeerService {
|
||||||
|
_FakeCompanionRemotePeerService({this.joinGate, this.disconnectGate, this.joinError});
|
||||||
|
|
||||||
|
final Completer<void>? joinGate;
|
||||||
|
final Completer<void>? disconnectGate;
|
||||||
|
final Object? joinError;
|
||||||
|
final Completer<void> joinStarted = Completer<void>();
|
||||||
|
final Completer<void> disconnectStarted = Completer<void>();
|
||||||
|
final List<RemoteCommand> sentCommands = [];
|
||||||
|
|
||||||
|
final StreamController<RemoteCommand> _commands = StreamController<RemoteCommand>.broadcast(sync: true);
|
||||||
|
final StreamController<RemoteDevice> _connected = StreamController<RemoteDevice>.broadcast(sync: true);
|
||||||
|
final StreamController<void> _disconnected = StreamController<void>.broadcast(sync: true);
|
||||||
|
final StreamController<RemotePeerError> _errors = StreamController<RemotePeerError>.broadcast(sync: true);
|
||||||
|
final StreamController<RemoteSessionStatus> _statuses = StreamController<RemoteSessionStatus>.broadcast(sync: true);
|
||||||
|
|
||||||
|
int disconnectCalls = 0;
|
||||||
|
int disposeCalls = 0;
|
||||||
|
bool _streamsClosed = false;
|
||||||
|
bool _serverRunning = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get isServerRunning => _serverRunning;
|
||||||
|
|
||||||
|
bool get hasListeners =>
|
||||||
|
_commands.hasListener ||
|
||||||
|
_connected.hasListener ||
|
||||||
|
_disconnected.hasListener ||
|
||||||
|
_errors.hasListener ||
|
||||||
|
_statuses.hasListener;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<RemoteCommand> get onCommandReceived => _commands.stream;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<RemoteDevice> get onDeviceConnected => _connected.stream;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<void> get onDeviceDisconnected => _disconnected.stream;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<RemotePeerError> get onError => _errors.stream;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<RemoteSessionStatus> get onConnectionStateChanged => _statuses.stream;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? get selectedAuthContextId => 'auth-context';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? get selectedHostClientId => 'host-client';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<({List<String> addresses, int port})> createSessionForContexts(
|
||||||
|
String deviceName,
|
||||||
|
String platform,
|
||||||
|
List<RemoteAuthContext> authContexts,
|
||||||
|
) async {
|
||||||
|
_serverRunning = true;
|
||||||
|
return (addresses: const ['127.0.0.1:48634'], port: 48634);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> joinSessionWithContexts(
|
||||||
|
String deviceName,
|
||||||
|
String platform,
|
||||||
|
String hostAddress,
|
||||||
|
List<RemoteAuthContext> authContexts, {
|
||||||
|
String? authContextId,
|
||||||
|
String expectedHostClientId = '',
|
||||||
|
}) async {
|
||||||
|
if (!joinStarted.isCompleted) joinStarted.complete();
|
||||||
|
final gate = joinGate;
|
||||||
|
if (gate != null) await gate.future;
|
||||||
|
final error = joinError;
|
||||||
|
if (error != null) throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String> joinSessionRacingWithContexts(
|
||||||
|
String deviceName,
|
||||||
|
String platform,
|
||||||
|
List<String> hostAddresses,
|
||||||
|
List<RemoteAuthContext> authContexts, {
|
||||||
|
String? authContextId,
|
||||||
|
String expectedHostClientId = '',
|
||||||
|
}) async {
|
||||||
|
await joinSessionWithContexts(
|
||||||
|
deviceName,
|
||||||
|
platform,
|
||||||
|
hostAddresses.first,
|
||||||
|
authContexts,
|
||||||
|
authContextId: authContextId,
|
||||||
|
expectedHostClientId: expectedHostClientId,
|
||||||
|
);
|
||||||
|
return hostAddresses.first;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void sendCommand(RemoteCommand command) {
|
||||||
|
sentCommands.add(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
void emitDeviceDisconnected() {
|
||||||
|
if (!_streamsClosed) _disconnected.add(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
void emitCommand(RemoteCommand command) {
|
||||||
|
if (!_streamsClosed) _commands.add(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
void emitError(RemotePeerError error) {
|
||||||
|
if (!_streamsClosed) _errors.add(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> disconnect() async {
|
||||||
|
disconnectCalls++;
|
||||||
|
_serverRunning = false;
|
||||||
|
if (!disconnectStarted.isCompleted) disconnectStarted.complete();
|
||||||
|
final gate = disconnectGate;
|
||||||
|
if (gate != null) await gate.future;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> dispose() async {
|
||||||
|
disposeCalls++;
|
||||||
|
await disconnect();
|
||||||
|
if (_streamsClosed) return;
|
||||||
|
_streamsClosed = true;
|
||||||
|
await Future.wait([
|
||||||
|
_commands.close(),
|
||||||
|
_connected.close(),
|
||||||
|
_disconnected.close(),
|
||||||
|
_errors.close(),
|
||||||
|
_statuses.close(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FakeLanDiscoveryService extends LanDiscoveryService {
|
||||||
|
bool _broadcasting = false;
|
||||||
|
bool _listening = false;
|
||||||
|
int stopBroadcastingCalls = 0;
|
||||||
|
int stopListeningCalls = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get isBroadcasting => _broadcasting;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get isListening => _listening;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> startBroadcastingForContexts({
|
||||||
|
required List<RemoteAuthContext> contexts,
|
||||||
|
required String deviceName,
|
||||||
|
required String platform,
|
||||||
|
required int wsPort,
|
||||||
|
required List<String> ips,
|
||||||
|
}) async {
|
||||||
|
_broadcasting = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> stopBroadcasting() async {
|
||||||
|
stopBroadcastingCalls++;
|
||||||
|
_broadcasting = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void stopListening() {
|
||||||
|
stopListeningCalls++;
|
||||||
|
_listening = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RemoteHarness {
|
||||||
|
_RemoteHarness({required this.provider, required this.database, required this.activeProfile, required this.plexHome});
|
||||||
|
|
||||||
|
final CompanionRemoteProvider provider;
|
||||||
|
final AppDatabase database;
|
||||||
|
final ActiveProfileProvider activeProfile;
|
||||||
|
final PlexHomeService plexHome;
|
||||||
|
bool _closed = false;
|
||||||
|
|
||||||
|
static Future<_RemoteHarness> create(
|
||||||
|
CompanionRemotePeerServiceFactory peerServiceFactory, {
|
||||||
|
LanDiscoveryServiceFactory discoveryServiceFactory = LanDiscoveryService.new,
|
||||||
|
}) async {
|
||||||
|
final database = AppDatabase.forTesting(NativeDatabase.memory());
|
||||||
|
final connections = ConnectionRegistry(database);
|
||||||
|
final profileConnections = ProfileConnectionRegistry(database);
|
||||||
|
final profiles = ProfileRegistry(database);
|
||||||
|
final storage = await StorageService.getInstance();
|
||||||
|
final plexHome = PlexHomeService(
|
||||||
|
connections: connections,
|
||||||
|
profileConnections: profileConnections,
|
||||||
|
storage: storage,
|
||||||
|
plexHomeUserFetcher: (_) async => const [],
|
||||||
|
);
|
||||||
|
final activeProfile = ActiveProfileProvider(
|
||||||
|
registry: profiles,
|
||||||
|
plexHome: plexHome,
|
||||||
|
connections: connections,
|
||||||
|
storage: storage,
|
||||||
|
);
|
||||||
|
|
||||||
|
final account = _plexAccount('remote-account', 'remote-client');
|
||||||
|
final profile = _localProfile('remote-profile');
|
||||||
|
await connections.upsert(account);
|
||||||
|
await profiles.upsert(profile);
|
||||||
|
await profileConnections.upsert(
|
||||||
|
ProfileConnection(profileId: profile.id, connectionId: account.id, userIdentifier: 'remote-admin'),
|
||||||
|
makeDefault: true,
|
||||||
|
);
|
||||||
|
await storage.setActiveProfileId(profile.id);
|
||||||
|
await activeProfile.initialize();
|
||||||
|
|
||||||
|
final provider = CompanionRemoteProvider.forTesting(
|
||||||
|
peerServiceFactory: peerServiceFactory,
|
||||||
|
discoveryServiceFactory: discoveryServiceFactory,
|
||||||
|
);
|
||||||
|
final ready = await provider.ensureCryptoReady(
|
||||||
|
_home('remote-admin'),
|
||||||
|
connections: connections,
|
||||||
|
activeProfile: activeProfile,
|
||||||
|
profileConnections: profileConnections,
|
||||||
|
account: account,
|
||||||
|
);
|
||||||
|
if (!ready) {
|
||||||
|
throw StateError('Remote test harness failed to initialize crypto');
|
||||||
|
}
|
||||||
|
|
||||||
|
return _RemoteHarness(provider: provider, database: database, activeProfile: activeProfile, plexHome: plexHome);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> close() async {
|
||||||
|
if (_closed) return;
|
||||||
|
_closed = true;
|
||||||
|
if (!provider.isDisposed) provider.dispose();
|
||||||
|
await activeProfile.resetForTesting();
|
||||||
|
activeProfile.dispose();
|
||||||
|
await plexHome.dispose();
|
||||||
|
await database.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,29 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:http/testing.dart';
|
||||||
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
|
import 'package:plezy/focus/input_mode_tracker.dart';
|
||||||
|
import 'package:plezy/i18n/strings.g.dart';
|
||||||
import 'package:plezy/screens/settings/logs_screen.dart';
|
import 'package:plezy/screens/settings/logs_screen.dart';
|
||||||
|
import 'package:plezy/utils/app_logger.dart';
|
||||||
|
import 'package:plezy/utils/media_server_http_client.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
setUpAll(() {
|
||||||
|
LocaleSettings.setLocaleSync(AppLocale.en);
|
||||||
|
});
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
MemoryLogOutput.clearLogs();
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
MemoryLogOutput.clearLogs();
|
||||||
|
});
|
||||||
test('log upload payload preserves the header and newest complete lines', () {
|
test('log upload payload preserves the header and newest complete lines', () {
|
||||||
const header = 'Plezy test device\n---\n';
|
const header = 'Plezy test device\n---\n';
|
||||||
const logs = 'oldest line that should be removed\nmiddle line that should be removed\nnewest 🚀 line';
|
const logs = 'oldest line that should be removed\nmiddle line that should be removed\nnewest 🚀 line';
|
||||||
@@ -22,4 +42,108 @@ void main() {
|
|||||||
|
|
||||||
expect(constrainLogUploadPayload(header: header, logs: logs, maxBytes: 128), '$header$logs');
|
expect(constrainLogUploadPayload(header: header, logs: logs, maxBytes: 128), '$header$logs');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('long upload capability displays and copies at narrow width', (tester) async {
|
||||||
|
const capability = 'abcdefghijklmnopqrstuvwxy';
|
||||||
|
String? uploadedBody;
|
||||||
|
http.Request? uploadedRequest;
|
||||||
|
String? clipboardText;
|
||||||
|
|
||||||
|
PackageInfo.setMockInitialValues(
|
||||||
|
appName: 'Plezy',
|
||||||
|
packageName: 'com.plezy.test',
|
||||||
|
version: '1.2.3',
|
||||||
|
buildNumber: '45',
|
||||||
|
buildSignature: '',
|
||||||
|
);
|
||||||
|
const deviceInfoChannel = MethodChannel('dev.fluttercommunity.plus/device_info');
|
||||||
|
final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||||
|
messenger.setMockMethodCallHandler(deviceInfoChannel, (call) async {
|
||||||
|
expect(call.method, 'getDeviceInfo');
|
||||||
|
return <String, dynamic>{
|
||||||
|
'computerName': 'test-mac',
|
||||||
|
'hostName': 'test-mac.local',
|
||||||
|
'arch': 'arm64',
|
||||||
|
'model': 'Mac15,3',
|
||||||
|
'modelName': 'Mac',
|
||||||
|
'kernelVersion': 'test',
|
||||||
|
'osRelease': '15.0',
|
||||||
|
'majorVersion': 15,
|
||||||
|
'minorVersion': 0,
|
||||||
|
'patchVersion': 0,
|
||||||
|
'activeCPUs': 8,
|
||||||
|
'memorySize': 16 * 1024 * 1024 * 1024,
|
||||||
|
'cpuFrequency': 0,
|
||||||
|
'systemGUID': 'test-guid',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
messenger.setMockMethodCallHandler(SystemChannels.platform, (call) async {
|
||||||
|
if (call.method == 'Clipboard.setData') {
|
||||||
|
clipboardText = (call.arguments as Map<Object?, Object?>)['text'] as String?;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
addTearDown(() {
|
||||||
|
messenger.setMockMethodCallHandler(deviceInfoChannel, null);
|
||||||
|
messenger.setMockMethodCallHandler(SystemChannels.platform, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
final client = MediaServerHttpClient(
|
||||||
|
client: MockClient((request) async {
|
||||||
|
uploadedRequest = request;
|
||||||
|
uploadedBody = request.body;
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode(<String, String>{'id': capability}),
|
||||||
|
200,
|
||||||
|
headers: {'content-type': 'application/json'},
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
addTearDown(client.close);
|
||||||
|
appLogger.i('widget upload seed');
|
||||||
|
|
||||||
|
tester.view.physicalSize = const Size(320, 640);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
TranslationProvider(
|
||||||
|
child: InputModeTracker(
|
||||||
|
child: MaterialApp(home: LogsScreen(httpClient: client)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byTooltip(t.logs.uploadLogs));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
expect(find.text(capability, findRichText: true), findsOneWidget);
|
||||||
|
expect(utf8.encode(uploadedBody!).length, lessThanOrEqualTo(maxLogUploadBytes));
|
||||||
|
expect(uploadedRequest!.method, 'POST');
|
||||||
|
expect(uploadedRequest!.url, Uri.parse('https://ice.plezy.app/logs'));
|
||||||
|
final contentType = uploadedRequest!.headers.entries
|
||||||
|
.singleWhere((entry) => entry.key.toLowerCase() == 'content-type')
|
||||||
|
.value;
|
||||||
|
expect(contentType, startsWith('text/plain'));
|
||||||
|
expect(uploadedBody, contains('widget upload seed'));
|
||||||
|
|
||||||
|
final dialog = find.byType(AlertDialog);
|
||||||
|
final copyButton = find.descendant(of: dialog, matching: find.byType(IconButton));
|
||||||
|
expect(copyButton, findsOneWidget);
|
||||||
|
await tester.tap(copyButton);
|
||||||
|
await tester.pump();
|
||||||
|
expect(clipboardText, capability);
|
||||||
|
|
||||||
|
clipboardText = null;
|
||||||
|
await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft);
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||||
|
await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||||
|
await tester.pump();
|
||||||
|
expect(clipboardText, capability);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -226,6 +226,39 @@ void main() {
|
|||||||
expect(materialUpdateTile.focusNode, isNotNull);
|
expect(materialUpdateTile.focusNode, isNotNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('relay dialog rejects invalid bases without persisting them', (tester) async {
|
||||||
|
final harness = await _pumpSettingsScreen(tester);
|
||||||
|
addTearDown(() => harness.dispose(tester));
|
||||||
|
|
||||||
|
await tester.tap(find.text(t.settings.watchTogetherRelay));
|
||||||
|
await _pumpUi(tester);
|
||||||
|
await tester.enterText(find.byType(TextField), 'ws://opaque-user@relay.example.test/path?wrong=route');
|
||||||
|
await tester.tap(find.text(t.common.save));
|
||||||
|
await _pumpUi(tester);
|
||||||
|
|
||||||
|
expect(find.byType(AlertDialog), findsOneWidget);
|
||||||
|
expect(find.text(t.settings.watchTogetherRelayInvalid), findsOneWidget);
|
||||||
|
expect(SettingsService.instance.read(SettingsService.customRelayUrl), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('relay dialog saves and reopens the canonical base', (tester) async {
|
||||||
|
final harness = await _pumpSettingsScreen(tester);
|
||||||
|
addTearDown(() => harness.dispose(tester));
|
||||||
|
|
||||||
|
await tester.tap(find.text(t.settings.watchTogetherRelay));
|
||||||
|
await _pumpUi(tester);
|
||||||
|
await tester.enterText(find.byType(TextField), ' HTTP://Relay.Example.Test:8080/prefix/// ');
|
||||||
|
await tester.tap(find.text(t.common.save));
|
||||||
|
await _pumpUi(tester);
|
||||||
|
|
||||||
|
expect(find.byType(AlertDialog), findsNothing);
|
||||||
|
expect(SettingsService.instance.read(SettingsService.customRelayUrl), 'http://relay.example.test:8080/prefix');
|
||||||
|
|
||||||
|
await tester.tap(find.text(t.settings.watchTogetherRelay));
|
||||||
|
await _pumpUi(tester);
|
||||||
|
expect(find.widgetWithText(TextField, 'http://relay.example.test:8080/prefix'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('folder replacement uses the provider coordinator', (tester) async {
|
testWidgets('folder replacement uses the provider coordinator', (tester) async {
|
||||||
final selectedDirectory = Directory('${temporaryDirectory.path}/selected-downloads');
|
final selectedDirectory = Directory('${temporaryDirectory.path}/selected-downloads');
|
||||||
directoryPicker.directoryPath = selectedDirectory.path;
|
directoryPicker.directoryPath = selectedDirectory.path;
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:plezy/i18n/strings.g.dart';
|
import 'package:plezy/i18n/strings.g.dart';
|
||||||
import 'package:plezy/models/companion_remote/remote_command.dart';
|
import 'package:plezy/models/companion_remote/remote_command.dart';
|
||||||
import 'package:plezy/services/companion_remote/companion_remote_peer_service.dart';
|
import 'package:plezy/services/companion_remote/companion_remote_peer_service.dart';
|
||||||
import 'package:plezy/services/companion_remote/remote_auth_context.dart';
|
import 'package:plezy/services/companion_remote/remote_auth_context.dart';
|
||||||
|
import 'package:plezy/services/companion_remote/remote_auth_service.dart';
|
||||||
|
|
||||||
|
const _ioTimeout = Duration(seconds: 5);
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
test('auth precondition error uses the active locale', () async {
|
test('auth precondition error uses the active locale', () async {
|
||||||
@@ -17,6 +24,428 @@ void main() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('per-source admission rejects before upgrade and recovers exactly one slot', () async {
|
||||||
|
final host = CompanionRemotePeerService.forTesting(maxTotalHostConnections: 4, maxHostConnectionsPerSource: 2);
|
||||||
|
final clients = <_RawWebSocketClient>[];
|
||||||
|
addTearDown(() => _disposeHostAndClients(host, clients));
|
||||||
|
final port = await _startHost(host);
|
||||||
|
|
||||||
|
clients.add(await _RawWebSocketClient.connect(port));
|
||||||
|
clients.add(await _RawWebSocketClient.connect(port));
|
||||||
|
await _expectWebSocketHandshakeStatus(port, HttpStatus.tooManyRequests);
|
||||||
|
expect(clients, everyElement(predicate<_RawWebSocketClient>((client) => client.isOpen)));
|
||||||
|
|
||||||
|
await clients.removeAt(0).dispose();
|
||||||
|
clients.add(await _RawWebSocketClient.connect(port));
|
||||||
|
await _expectWebSocketHandshakeStatus(port, HttpStatus.tooManyRequests);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('global admission rejects independently and releases one reservation once', () async {
|
||||||
|
final host = CompanionRemotePeerService.forTesting(maxTotalHostConnections: 2, maxHostConnectionsPerSource: 4);
|
||||||
|
final clients = <_RawWebSocketClient>[];
|
||||||
|
addTearDown(() => _disposeHostAndClients(host, clients));
|
||||||
|
final port = await _startHost(host);
|
||||||
|
|
||||||
|
clients.add(await _RawWebSocketClient.connect(port));
|
||||||
|
clients.add(await _RawWebSocketClient.connect(port));
|
||||||
|
await _expectWebSocketHandshakeStatus(port, HttpStatus.tooManyRequests);
|
||||||
|
|
||||||
|
await clients.removeAt(0).dispose();
|
||||||
|
clients.add(await _RawWebSocketClient.connect(port));
|
||||||
|
await _expectWebSocketHandshakeStatus(port, HttpStatus.tooManyRequests);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('authentication timeout releases capacity for a legitimate encrypted session', () async {
|
||||||
|
final host = CompanionRemotePeerService.forTesting(
|
||||||
|
maxTotalHostConnections: 1,
|
||||||
|
maxHostConnectionsPerSource: 1,
|
||||||
|
authTimeout: const Duration(milliseconds: 300),
|
||||||
|
);
|
||||||
|
final remote = CompanionRemotePeerService();
|
||||||
|
final clients = <_RawWebSocketClient>[];
|
||||||
|
addTearDown(() async {
|
||||||
|
await remote.dispose();
|
||||||
|
await _disposeHostAndClients(host, clients);
|
||||||
|
});
|
||||||
|
final port = await _startHost(host);
|
||||||
|
|
||||||
|
final silent = await _RawWebSocketClient.connect(port);
|
||||||
|
clients.add(silent);
|
||||||
|
expect(await silent.closed.timeout(_ioTimeout), 4001);
|
||||||
|
clients.remove(silent);
|
||||||
|
|
||||||
|
await remote.joinSessionWithContexts(
|
||||||
|
'Test Remote',
|
||||||
|
'ios',
|
||||||
|
'127.0.0.1:$port',
|
||||||
|
[_authContext],
|
||||||
|
authContextId: _authContext.id,
|
||||||
|
expectedHostClientId: _authContext.clientIdentifier,
|
||||||
|
);
|
||||||
|
final command = host.onCommandReceived.firstWhere((event) => event.type == RemoteCommandType.play);
|
||||||
|
remote.sendCommand(const RemoteCommand(type: RemoteCommandType.play));
|
||||||
|
expect((await command.timeout(_ioTimeout)).type, RemoteCommandType.play);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disconnect closes pending sockets and restart restores full capacity', () async {
|
||||||
|
final host = CompanionRemotePeerService.forTesting(
|
||||||
|
maxTotalHostConnections: 2,
|
||||||
|
maxHostConnectionsPerSource: 2,
|
||||||
|
authTimeout: const Duration(seconds: 1),
|
||||||
|
);
|
||||||
|
final clients = <_RawWebSocketClient>[];
|
||||||
|
addTearDown(() => _disposeHostAndClients(host, clients));
|
||||||
|
var port = await _startHost(host);
|
||||||
|
|
||||||
|
final firstGeneration = [await _RawWebSocketClient.connect(port), await _RawWebSocketClient.connect(port)];
|
||||||
|
clients.addAll(firstGeneration);
|
||||||
|
await host.disconnect();
|
||||||
|
expect(
|
||||||
|
await Future.wait(firstGeneration.map((client) => client.closed.timeout(_ioTimeout))),
|
||||||
|
everyElement(isNot(4001)),
|
||||||
|
);
|
||||||
|
clients.removeWhere(firstGeneration.contains);
|
||||||
|
|
||||||
|
port = await _startHost(host);
|
||||||
|
clients.add(await _RawWebSocketClient.connect(port));
|
||||||
|
clients.add(await _RawWebSocketClient.connect(port));
|
||||||
|
await _expectWebSocketHandshakeStatus(port, HttpStatus.tooManyRequests);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failed HTTP upgrade and abrupt socket loss both release the only slot', () async {
|
||||||
|
final host = CompanionRemotePeerService.forTesting(maxTotalHostConnections: 1, maxHostConnectionsPerSource: 1);
|
||||||
|
final clients = <_RawWebSocketClient>[];
|
||||||
|
addTearDown(() => _disposeHostAndClients(host, clients));
|
||||||
|
final port = await _startHost(host);
|
||||||
|
|
||||||
|
final httpClient = HttpClient();
|
||||||
|
addTearDown(() => httpClient.close(force: true));
|
||||||
|
final request = await httpClient.getUrl(Uri.parse('http://127.0.0.1:$port/ws'));
|
||||||
|
final response = await request.close();
|
||||||
|
expect(response.statusCode, HttpStatus.badRequest);
|
||||||
|
await response.drain<void>();
|
||||||
|
|
||||||
|
final afterFailedUpgrade = await _RawWebSocketClient.connect(port);
|
||||||
|
clients.add(afterFailedUpgrade);
|
||||||
|
await afterFailedUpgrade.dispose();
|
||||||
|
clients.remove(afterFailedUpgrade);
|
||||||
|
|
||||||
|
final abrupt = await _RawUpgradedSocket.connect(port);
|
||||||
|
addTearDown(abrupt.dispose);
|
||||||
|
await abrupt.dispose();
|
||||||
|
await _flushEventQueue();
|
||||||
|
|
||||||
|
clients.add(await _RawWebSocketClient.connect(port));
|
||||||
|
await _expectWebSocketHandshakeStatus(port, HttpStatus.tooManyRequests);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setup failure after upgrade closes the socket and releases the only slot', () async {
|
||||||
|
var rejectSetup = true;
|
||||||
|
final host = CompanionRemotePeerService.forTesting(
|
||||||
|
maxTotalHostConnections: 1,
|
||||||
|
maxHostConnectionsPerSource: 1,
|
||||||
|
afterHostUpgrade: () {
|
||||||
|
if (!rejectSetup) return;
|
||||||
|
rejectSetup = false;
|
||||||
|
throw StateError('injected admission setup failure');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
final clients = <_RawWebSocketClient>[];
|
||||||
|
addTearDown(() => _disposeHostAndClients(host, clients));
|
||||||
|
final port = await _startHost(host);
|
||||||
|
|
||||||
|
final rejected = await WebSocket.connect('ws://127.0.0.1:$port/ws');
|
||||||
|
try {
|
||||||
|
await rejected.drain<void>().timeout(_ioTimeout);
|
||||||
|
expect(rejected.readyState, WebSocket.closed);
|
||||||
|
} finally {
|
||||||
|
await rejected.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
clients.add(await _RawWebSocketClient.connect(port));
|
||||||
|
await _expectWebSocketHandshakeStatus(port, HttpStatus.tooManyRequests);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('oversized and malformed first messages close generically and always restore capacity', () async {
|
||||||
|
final host = CompanionRemotePeerService.forTesting(
|
||||||
|
maxTotalHostConnections: 1,
|
||||||
|
maxHostConnectionsPerSource: 1,
|
||||||
|
maxPreAuthMessageBytes: 256,
|
||||||
|
maxFailedAuthAttempts: 20,
|
||||||
|
);
|
||||||
|
final clients = <_RawWebSocketClient>[];
|
||||||
|
final connected = <Object>[];
|
||||||
|
final commands = <RemoteCommand>[];
|
||||||
|
final connectedSubscription = host.onDeviceConnected.listen(connected.add);
|
||||||
|
final commandSubscription = host.onCommandReceived.listen(commands.add);
|
||||||
|
addTearDown(() async {
|
||||||
|
await connectedSubscription.cancel();
|
||||||
|
await commandSubscription.cancel();
|
||||||
|
await _disposeHostAndClients(host, clients);
|
||||||
|
});
|
||||||
|
final port = await _startHost(host);
|
||||||
|
|
||||||
|
final cases = <({String name, dynamic payload, int closeCode})>[
|
||||||
|
(name: 'oversized text', payload: 'x' * 257, closeCode: 4003),
|
||||||
|
(name: 'oversized UTF-8 text', payload: 'é' * 129, closeCode: 4003),
|
||||||
|
(name: 'binary input', payload: <int>[1, 2, 3], closeCode: 4003),
|
||||||
|
(name: 'invalid JSON', payload: '{', closeCode: 4003),
|
||||||
|
(name: 'non-map JSON', payload: '[]', closeCode: 4003),
|
||||||
|
(name: 'invalid Base64', payload: _minimalAuth(clientNonce: '%%%'), closeCode: 4003),
|
||||||
|
(name: 'wrong nonce length', payload: _minimalAuth(clientNonce: base64Encode([1, 2])), closeCode: 4003),
|
||||||
|
(name: 'missing fields', payload: jsonEncode({'type': 'auth'}), closeCode: 4003),
|
||||||
|
(
|
||||||
|
name: 'wrong field type',
|
||||||
|
payload: jsonEncode({
|
||||||
|
'type': 'auth',
|
||||||
|
'authTag': 1,
|
||||||
|
'clientNonce': base64Encode(List<int>.filled(32, 1)),
|
||||||
|
'userUUID': 'user-1',
|
||||||
|
'clientIdentifier': 'remote-client',
|
||||||
|
'deviceName': 'Remote',
|
||||||
|
'platform': 'test',
|
||||||
|
}),
|
||||||
|
closeCode: 4003,
|
||||||
|
),
|
||||||
|
(name: 'unexpected type', payload: jsonEncode({'type': 'ping'}), closeCode: 4002),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (final malformedCase in cases) {
|
||||||
|
final client = await _RawWebSocketClient.connect(port);
|
||||||
|
clients.add(client);
|
||||||
|
client.add(malformedCase.payload);
|
||||||
|
await client.authFailed.timeout(_ioTimeout);
|
||||||
|
expect(await client.closed.timeout(_ioTimeout), malformedCase.closeCode, reason: malformedCase.name);
|
||||||
|
clients.remove(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
final capacityProbe = await _RawWebSocketClient.connect(port);
|
||||||
|
clients.add(capacityProbe);
|
||||||
|
expect(capacityProbe.isOpen, isTrue);
|
||||||
|
expect(connected, isEmpty);
|
||||||
|
expect(commands, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('malformed first message counts once, ignores a queued valid auth, and locks out before upgrade', () async {
|
||||||
|
final host = CompanionRemotePeerService.forTesting(
|
||||||
|
maxTotalHostConnections: 2,
|
||||||
|
maxHostConnectionsPerSource: 2,
|
||||||
|
maxFailedAuthAttempts: 2,
|
||||||
|
);
|
||||||
|
final clients = <_RawWebSocketClient>[];
|
||||||
|
final connected = <Object>[];
|
||||||
|
final subscription = host.onDeviceConnected.listen(connected.add);
|
||||||
|
addTearDown(() async {
|
||||||
|
await subscription.cancel();
|
||||||
|
await _disposeHostAndClients(host, clients);
|
||||||
|
});
|
||||||
|
final port = await _startHost(host);
|
||||||
|
|
||||||
|
final first = await _RawWebSocketClient.connect(port);
|
||||||
|
clients.add(first);
|
||||||
|
final validAuth = _validAuthMessage(first.challenge, _authContext);
|
||||||
|
first.add('{');
|
||||||
|
first.add(validAuth);
|
||||||
|
await first.authFailed.timeout(_ioTimeout);
|
||||||
|
expect(await first.closed.timeout(_ioTimeout), 4003);
|
||||||
|
clients.remove(first);
|
||||||
|
|
||||||
|
final second = await _RawWebSocketClient.connect(port);
|
||||||
|
clients.add(second);
|
||||||
|
second.add(<int>[9]);
|
||||||
|
await second.authFailed.timeout(_ioTimeout);
|
||||||
|
expect(await second.closed.timeout(_ioTimeout), 4003);
|
||||||
|
clients.remove(second);
|
||||||
|
|
||||||
|
await _expectWebSocketHandshakeStatus(port, HttpStatus.tooManyRequests);
|
||||||
|
expect(connected, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unknown context, disallowed user, and bad HMAC share the terminal auth failure path', () async {
|
||||||
|
final host = CompanionRemotePeerService.forTesting(maxFailedAuthAttempts: 10);
|
||||||
|
final clients = <_RawWebSocketClient>[];
|
||||||
|
addTearDown(() => _disposeHostAndClients(host, clients));
|
||||||
|
final port = await _startHost(host);
|
||||||
|
|
||||||
|
final attempts = <String Function(_RawWebSocketClient)>[
|
||||||
|
(client) => _validAuthMessage(client.challenge, _authContext, authContextId: 'missing-context'),
|
||||||
|
(client) => _validAuthMessage(client.challenge, _authContext, userUuid: 'disallowed-user'),
|
||||||
|
(client) => _validAuthMessage(client.challenge, _authContext, authTag: 'invalid-tag'),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (final message in attempts) {
|
||||||
|
final client = await _RawWebSocketClient.connect(port);
|
||||||
|
clients.add(client);
|
||||||
|
client.add(message(client));
|
||||||
|
await client.authFailed.timeout(_ioTimeout);
|
||||||
|
expect(await client.closed.timeout(_ioTimeout), 4003);
|
||||||
|
clients.remove(client);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('single-context auth remains compatible without authContextId', () async {
|
||||||
|
final host = CompanionRemotePeerService.forTesting();
|
||||||
|
final clients = <_RawWebSocketClient>[];
|
||||||
|
addTearDown(() => _disposeHostAndClients(host, clients));
|
||||||
|
final port = await _startHost(host);
|
||||||
|
|
||||||
|
final client = await _RawWebSocketClient.connect(port);
|
||||||
|
clients.add(client);
|
||||||
|
final connected = host.onDeviceConnected.first;
|
||||||
|
client.add(_validAuthMessage(client.challenge, _authContext, includeAuthContextId: false));
|
||||||
|
|
||||||
|
await connected.timeout(_ioTimeout);
|
||||||
|
expect(host.isConnected, isTrue);
|
||||||
|
expect(host.selectedAuthContextId, _authContext.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('closed authentication race cannot commit and replacement late close cannot clear the winner', () async {
|
||||||
|
final secondDerivationEntered = Completer<void>();
|
||||||
|
final releaseSecondDerivation = Completer<void>();
|
||||||
|
final secondDerivationReturned = Completer<void>();
|
||||||
|
var derivationCount = 0;
|
||||||
|
final host = CompanionRemotePeerService.forTesting(
|
||||||
|
deriveSessionEncKey: (homeSecret, hostNonce, clientNonce) async {
|
||||||
|
derivationCount++;
|
||||||
|
if (derivationCount == 2) {
|
||||||
|
secondDerivationEntered.complete();
|
||||||
|
await releaseSecondDerivation.future;
|
||||||
|
}
|
||||||
|
final key = await RemoteAuthService.instance.deriveSessionEncKey(homeSecret, hostNonce, clientNonce);
|
||||||
|
if (derivationCount == 2 && !secondDerivationReturned.isCompleted) {
|
||||||
|
secondDerivationReturned.complete();
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
final clients = <_RawWebSocketClient>[];
|
||||||
|
final connected = <Object>[];
|
||||||
|
final subscription = host.onDeviceConnected.listen(connected.add);
|
||||||
|
addTearDown(() async {
|
||||||
|
if (!releaseSecondDerivation.isCompleted) releaseSecondDerivation.complete();
|
||||||
|
await subscription.cancel();
|
||||||
|
await _disposeHostAndClients(host, clients);
|
||||||
|
});
|
||||||
|
final port = await _startHost(host);
|
||||||
|
|
||||||
|
final original = await _RawWebSocketClient.connect(port);
|
||||||
|
clients.add(original);
|
||||||
|
final originalConnected = host.onDeviceConnected.first;
|
||||||
|
original.add(_validAuthMessage(original.challenge, _authContext, deviceName: 'Original'));
|
||||||
|
await originalConnected.timeout(_ioTimeout);
|
||||||
|
expect(host.isConnected, isTrue);
|
||||||
|
|
||||||
|
final racing = await _RawWebSocketClient.connect(port);
|
||||||
|
clients.add(racing);
|
||||||
|
racing.add(_validAuthMessage(racing.challenge, _authContext, deviceName: 'Closed racer'));
|
||||||
|
await secondDerivationEntered.future.timeout(_ioTimeout);
|
||||||
|
await racing.dispose();
|
||||||
|
clients.remove(racing);
|
||||||
|
releaseSecondDerivation.complete();
|
||||||
|
await secondDerivationReturned.future.timeout(_ioTimeout);
|
||||||
|
await _flushEventQueue();
|
||||||
|
|
||||||
|
expect(host.isConnected, isTrue);
|
||||||
|
expect(original.isOpen, isTrue);
|
||||||
|
expect(connected, hasLength(1));
|
||||||
|
|
||||||
|
final replacement = await _RawWebSocketClient.connect(port);
|
||||||
|
clients.add(replacement);
|
||||||
|
final replacementConnected = host.onDeviceConnected.first;
|
||||||
|
replacement.add(_validAuthMessage(replacement.challenge, _authContext, deviceName: 'Replacement'));
|
||||||
|
await replacementConnected.timeout(_ioTimeout);
|
||||||
|
expect(await original.closed.timeout(_ioTimeout), 4004);
|
||||||
|
clients.remove(original);
|
||||||
|
await _flushEventQueue();
|
||||||
|
|
||||||
|
expect(host.isConnected, isTrue);
|
||||||
|
expect(connected, hasLength(2));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('address probes release every source slot before the managed connection', () async {
|
||||||
|
final host = CompanionRemotePeerService.forTesting(maxTotalHostConnections: 2, maxHostConnectionsPerSource: 2);
|
||||||
|
final remote = CompanionRemotePeerService();
|
||||||
|
addTearDown(() async {
|
||||||
|
await remote.dispose();
|
||||||
|
await host.dispose();
|
||||||
|
});
|
||||||
|
final port = await _startHost(host);
|
||||||
|
final address = '127.0.0.1:$port';
|
||||||
|
|
||||||
|
final winner = await remote.joinSessionRacingWithContexts(
|
||||||
|
'Test Remote',
|
||||||
|
'ios',
|
||||||
|
[address, address],
|
||||||
|
[_authContext],
|
||||||
|
authContextId: _authContext.id,
|
||||||
|
expectedHostClientId: _authContext.clientIdentifier,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(winner, address);
|
||||||
|
final command = host.onCommandReceived.firstWhere((event) => event.type == RemoteCommandType.play);
|
||||||
|
remote.sendCommand(const RemoteCommand(type: RemoteCommandType.play));
|
||||||
|
expect((await command.timeout(_ioTimeout)).type, RemoteCommandType.play);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('managed join waits for every probe terminal event and listener cleanup', () async {
|
||||||
|
final closeRequested = List.generate(2, (_) => Completer<void>());
|
||||||
|
final listenerCleanedUp = List.generate(2, (_) => Completer<void>());
|
||||||
|
final controllers = List.generate(2, (_) => StreamController<dynamic>(sync: true));
|
||||||
|
var nextProbe = 0;
|
||||||
|
final managedJoinStarted = Completer<void>();
|
||||||
|
late final _ManagedJoinObservingPeer remote;
|
||||||
|
remote = _ManagedJoinObservingPeer(
|
||||||
|
raceProbeFactory: (_) {
|
||||||
|
final index = nextProbe++;
|
||||||
|
return (
|
||||||
|
close: () async {
|
||||||
|
if (!closeRequested[index].isCompleted) {
|
||||||
|
closeRequested[index].complete();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ready: Future<void>.value(),
|
||||||
|
stream: _CancelTrackingStream(controllers[index].stream, () {
|
||||||
|
if (!listenerCleanedUp[index].isCompleted) {
|
||||||
|
listenerCleanedUp[index].complete();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onManagedJoin: () async {
|
||||||
|
expect(listenerCleanedUp, everyElement(predicate<Completer<void>>((item) => item.isCompleted)));
|
||||||
|
managedJoinStarted.complete();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
addTearDown(() async {
|
||||||
|
for (final controller in controllers) {
|
||||||
|
if (!controller.isClosed) await controller.close();
|
||||||
|
}
|
||||||
|
await remote.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
final join = remote.joinSessionRacingWithContexts(
|
||||||
|
'Test Remote',
|
||||||
|
'ios',
|
||||||
|
const ['probe-a', 'probe-b'],
|
||||||
|
[_authContext],
|
||||||
|
);
|
||||||
|
expect(nextProbe, 2);
|
||||||
|
|
||||||
|
controllers.first.add(jsonEncode({'type': 'challenge'}));
|
||||||
|
await closeRequested.first.future.timeout(_ioTimeout);
|
||||||
|
expect(managedJoinStarted.isCompleted, isFalse);
|
||||||
|
|
||||||
|
await controllers.first.close();
|
||||||
|
await closeRequested.last.future.timeout(_ioTimeout);
|
||||||
|
expect(listenerCleanedUp.first.isCompleted, isTrue);
|
||||||
|
expect(managedJoinStarted.isCompleted, isFalse);
|
||||||
|
|
||||||
|
await controllers.last.close();
|
||||||
|
expect(await join.timeout(_ioTimeout), 'probe-a');
|
||||||
|
expect(listenerCleanedUp, everyElement(predicate<Completer<void>>((item) => item.isCompleted)));
|
||||||
|
expect(managedJoinStarted.isCompleted, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
test('host and remote dispatch encrypted commands through the same contract', () async {
|
test('host and remote dispatch encrypted commands through the same contract', () async {
|
||||||
final host = CompanionRemotePeerService();
|
final host = CompanionRemotePeerService();
|
||||||
final remote = CompanionRemotePeerService();
|
final remote = CompanionRemotePeerService();
|
||||||
@@ -25,39 +454,320 @@ void main() {
|
|||||||
await host.dispose();
|
await host.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
final context = RemoteAuthContext(
|
final session = await host.createSessionForContexts('Test Host', 'macos', [_authContext]);
|
||||||
id: 'context-1',
|
|
||||||
backend: 'plex',
|
|
||||||
connectionId: 'connection-1',
|
|
||||||
homeSecret: List<int>.generate(32, (index) => index),
|
|
||||||
discoveryKey: List<int>.generate(32, (index) => 255 - index),
|
|
||||||
clientIdentifier: 'host-client',
|
|
||||||
userUuid: 'user-1',
|
|
||||||
allowedUserUuids: const ['user-1'],
|
|
||||||
);
|
|
||||||
|
|
||||||
final session = await host.createSessionForContexts('Test Host', 'macos', [context]);
|
|
||||||
await remote.joinSessionWithContexts(
|
await remote.joinSessionWithContexts(
|
||||||
'Test Remote',
|
'Test Remote',
|
||||||
'ios',
|
'ios',
|
||||||
'127.0.0.1:${session.port}',
|
'127.0.0.1:${session.port}',
|
||||||
[context],
|
[_authContext],
|
||||||
authContextId: context.id,
|
authContextId: _authContext.id,
|
||||||
expectedHostClientId: context.clientIdentifier,
|
expectedHostClientId: _authContext.clientIdentifier,
|
||||||
);
|
);
|
||||||
|
|
||||||
final hostCommand = host.onCommandReceived.firstWhere((command) => command.type == RemoteCommandType.play);
|
final hostCommand = host.onCommandReceived.firstWhere((command) => command.type == RemoteCommandType.play);
|
||||||
remote.sendCommand(const RemoteCommand(type: RemoteCommandType.play, data: {'source': 'remote'}));
|
remote.sendCommand(const RemoteCommand(type: RemoteCommandType.play, data: {'source': 'remote'}));
|
||||||
expect(
|
expect(
|
||||||
await hostCommand.timeout(const Duration(seconds: 5)),
|
await hostCommand.timeout(_ioTimeout),
|
||||||
const RemoteCommand(type: RemoteCommandType.play, data: {'source': 'remote'}),
|
const RemoteCommand(type: RemoteCommandType.play, data: {'source': 'remote'}),
|
||||||
);
|
);
|
||||||
|
|
||||||
final remoteCommand = remote.onCommandReceived.firstWhere((command) => command.type == RemoteCommandType.pause);
|
final remoteCommand = remote.onCommandReceived.firstWhere((command) => command.type == RemoteCommandType.pause);
|
||||||
host.sendCommand(const RemoteCommand(type: RemoteCommandType.pause, data: {'source': 'host'}));
|
host.sendCommand(const RemoteCommand(type: RemoteCommandType.pause, data: {'source': 'host'}));
|
||||||
expect(
|
expect(
|
||||||
await remoteCommand.timeout(const Duration(seconds: 5)),
|
await remoteCommand.timeout(_ioTimeout),
|
||||||
const RemoteCommand(type: RemoteCommandType.pause, data: {'source': 'host'}),
|
const RemoteCommand(type: RemoteCommandType.pause, data: {'source': 'host'}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
typedef _TestRaceProbeConnection = ({Future<void> Function() close, Future<void> ready, Stream<dynamic> stream});
|
||||||
|
|
||||||
|
class _ManagedJoinObservingPeer extends CompanionRemotePeerService {
|
||||||
|
_ManagedJoinObservingPeer({
|
||||||
|
required _TestRaceProbeConnection Function(Uri uri) raceProbeFactory,
|
||||||
|
required this._onManagedJoin,
|
||||||
|
}) : super.forTesting(raceProbeFactory: raceProbeFactory);
|
||||||
|
|
||||||
|
final Future<void> Function() _onManagedJoin;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> joinSessionWithContexts(
|
||||||
|
String deviceName,
|
||||||
|
String platform,
|
||||||
|
String hostAddress,
|
||||||
|
List<RemoteAuthContext> authContexts, {
|
||||||
|
String? authContextId,
|
||||||
|
String expectedHostClientId = '',
|
||||||
|
}) {
|
||||||
|
return _onManagedJoin();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CancelTrackingStream<T> extends Stream<T> {
|
||||||
|
_CancelTrackingStream(this._delegate, this._onCancel);
|
||||||
|
|
||||||
|
final Stream<T> _delegate;
|
||||||
|
final void Function() _onCancel;
|
||||||
|
|
||||||
|
@override
|
||||||
|
StreamSubscription<T> listen(
|
||||||
|
void Function(T event)? onData, {
|
||||||
|
Function? onError,
|
||||||
|
void Function()? onDone,
|
||||||
|
bool? cancelOnError,
|
||||||
|
}) {
|
||||||
|
return _CancelTrackingSubscription(
|
||||||
|
_delegate.listen(onData, onError: onError, onDone: onDone, cancelOnError: cancelOnError),
|
||||||
|
_onCancel,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CancelTrackingSubscription<T> implements StreamSubscription<T> {
|
||||||
|
_CancelTrackingSubscription(this._delegate, this._onCancel);
|
||||||
|
|
||||||
|
final StreamSubscription<T> _delegate;
|
||||||
|
final void Function() _onCancel;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> cancel() {
|
||||||
|
_onCancel();
|
||||||
|
return _delegate.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onData(void Function(T data)? handleData) => _delegate.onData(handleData);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onError(Function? handleError) => _delegate.onError(handleError);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onDone(void Function()? handleDone) => _delegate.onDone(handleDone);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void pause([Future<void>? resumeSignal]) => _delegate.pause(resumeSignal);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void resume() => _delegate.resume();
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get isPaused => _delegate.isPaused;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<E> asFuture<E>([E? futureValue]) => _delegate.asFuture(futureValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
final _authContext = RemoteAuthContext(
|
||||||
|
id: 'context-1',
|
||||||
|
backend: 'plex',
|
||||||
|
connectionId: 'connection-1',
|
||||||
|
homeSecret: List<int>.generate(32, (index) => index),
|
||||||
|
discoveryKey: List<int>.generate(32, (index) => 255 - index),
|
||||||
|
clientIdentifier: 'host-client',
|
||||||
|
userUuid: 'user-1',
|
||||||
|
allowedUserUuids: const ['user-1'],
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<int> _startHost(CompanionRemotePeerService host) async {
|
||||||
|
final session = await host.createSessionForContexts('Test Host', 'macos', [_authContext]);
|
||||||
|
return session.port;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _disposeHostAndClients(CompanionRemotePeerService host, List<_RawWebSocketClient> clients) async {
|
||||||
|
for (final client in List<_RawWebSocketClient>.of(clients)) {
|
||||||
|
await client.dispose();
|
||||||
|
}
|
||||||
|
clients.clear();
|
||||||
|
await host.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _expectWebSocketHandshakeStatus(int port, int statusCode) async {
|
||||||
|
await expectLater(
|
||||||
|
WebSocket.connect('ws://127.0.0.1:$port/ws'),
|
||||||
|
throwsA(
|
||||||
|
isA<WebSocketException>().having((error) => error.toString(), 'message', contains('status code: $statusCode')),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _minimalAuth({required String clientNonce}) {
|
||||||
|
return jsonEncode({
|
||||||
|
'type': 'auth',
|
||||||
|
'authTag': 'tag',
|
||||||
|
'clientNonce': clientNonce,
|
||||||
|
'userUUID': 'user-1',
|
||||||
|
'clientIdentifier': 'remote-client',
|
||||||
|
'deviceName': 'Remote',
|
||||||
|
'platform': 'test',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
String _validAuthMessage(
|
||||||
|
Map<String, dynamic> challenge,
|
||||||
|
RemoteAuthContext context, {
|
||||||
|
String? authContextId,
|
||||||
|
String? userUuid,
|
||||||
|
String? authTag,
|
||||||
|
String deviceName = 'Raw Remote',
|
||||||
|
bool includeAuthContextId = true,
|
||||||
|
}) {
|
||||||
|
final hostNonce = base64Decode(challenge['nonce'] as String);
|
||||||
|
final clientNonce = RemoteAuthService.instance.generateNonce();
|
||||||
|
final selectedUserUuid = userUuid ?? context.userUuid;
|
||||||
|
final computedAuthTag = RemoteAuthService.instance.computeAuthTag(
|
||||||
|
homeSecret: context.homeSecret,
|
||||||
|
hostNonce: hostNonce,
|
||||||
|
clientNonce: clientNonce,
|
||||||
|
hostClientId: context.clientIdentifier,
|
||||||
|
userUUID: selectedUserUuid,
|
||||||
|
clientIdentifier: 'raw-remote-client',
|
||||||
|
deviceName: deviceName,
|
||||||
|
platform: 'test',
|
||||||
|
);
|
||||||
|
return jsonEncode({
|
||||||
|
'type': 'auth',
|
||||||
|
if (includeAuthContextId) 'authContextId': authContextId ?? context.id,
|
||||||
|
'clientNonce': base64Encode(clientNonce),
|
||||||
|
'userUUID': selectedUserUuid,
|
||||||
|
'clientIdentifier': 'raw-remote-client',
|
||||||
|
'deviceName': deviceName,
|
||||||
|
'platform': 'test',
|
||||||
|
'authTag': authTag ?? computedAuthTag,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _flushEventQueue() async {
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RawWebSocketClient {
|
||||||
|
_RawWebSocketClient._(this.socket) {
|
||||||
|
_subscription = socket.listen(
|
||||||
|
(data) {
|
||||||
|
if (data is! String) return;
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(data);
|
||||||
|
if (decoded is! Map<String, dynamic>) return;
|
||||||
|
if (decoded['type'] == 'challenge' && !_challenge.isCompleted) {
|
||||||
|
_challengeValue = decoded;
|
||||||
|
_challenge.complete(decoded);
|
||||||
|
} else if (decoded['type'] == 'authFailed' && !_authFailed.isCompleted) {
|
||||||
|
_authFailed.complete();
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Encrypted and malformed server messages are not part of this helper.
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDone: () {
|
||||||
|
if (!_closed.isCompleted) _closed.complete(socket.closeCode);
|
||||||
|
},
|
||||||
|
onError: (Object error, StackTrace stackTrace) {
|
||||||
|
if (!_closed.isCompleted) _closed.completeError(error, stackTrace);
|
||||||
|
},
|
||||||
|
cancelOnError: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<_RawWebSocketClient> connect(int port) async {
|
||||||
|
final socket = await WebSocket.connect('ws://127.0.0.1:$port/ws').timeout(_ioTimeout);
|
||||||
|
final client = _RawWebSocketClient._(socket);
|
||||||
|
try {
|
||||||
|
await client._challenge.future.timeout(_ioTimeout);
|
||||||
|
return client;
|
||||||
|
} catch (_) {
|
||||||
|
await client.dispose();
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final WebSocket socket;
|
||||||
|
final Completer<Map<String, dynamic>> _challenge = Completer<Map<String, dynamic>>();
|
||||||
|
final Completer<void> _authFailed = Completer<void>();
|
||||||
|
final Completer<int?> _closed = Completer<int?>();
|
||||||
|
late final StreamSubscription<dynamic> _subscription;
|
||||||
|
late Map<String, dynamic> _challengeValue;
|
||||||
|
|
||||||
|
Map<String, dynamic> get challenge => _challengeValue;
|
||||||
|
Future<void> get authFailed => _authFailed.future;
|
||||||
|
Future<int?> get closed => _closed.future;
|
||||||
|
bool get isOpen => socket.readyState == WebSocket.open;
|
||||||
|
|
||||||
|
void add(dynamic data) => socket.add(data);
|
||||||
|
|
||||||
|
Future<void> dispose() async {
|
||||||
|
if (!_closed.isCompleted) {
|
||||||
|
try {
|
||||||
|
await socket.close(WebSocketStatus.normalClosure);
|
||||||
|
} catch (_) {
|
||||||
|
// The peer may already have closed the transport.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!_closed.isCompleted) {
|
||||||
|
await _closed.future.timeout(_ioTimeout);
|
||||||
|
}
|
||||||
|
await _subscription.cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RawUpgradedSocket {
|
||||||
|
_RawUpgradedSocket._(this.socket, this._subscription, this._done);
|
||||||
|
|
||||||
|
static Future<_RawUpgradedSocket> connect(int port) async {
|
||||||
|
final socket = await Socket.connect(InternetAddress.loopbackIPv4, port).timeout(_ioTimeout);
|
||||||
|
final responseHeaders = StringBuffer();
|
||||||
|
final upgraded = Completer<void>();
|
||||||
|
final done = Completer<void>();
|
||||||
|
late final StreamSubscription<List<int>> subscription;
|
||||||
|
subscription = socket.listen(
|
||||||
|
(bytes) {
|
||||||
|
responseHeaders.write(latin1.decode(bytes));
|
||||||
|
if (!upgraded.isCompleted && responseHeaders.toString().contains('\r\n\r\n')) {
|
||||||
|
upgraded.complete();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (Object error, StackTrace stackTrace) {
|
||||||
|
if (!upgraded.isCompleted) upgraded.completeError(error, stackTrace);
|
||||||
|
if (!done.isCompleted) done.complete();
|
||||||
|
},
|
||||||
|
onDone: () {
|
||||||
|
if (!upgraded.isCompleted) {
|
||||||
|
upgraded.completeError(StateError('Socket closed before WebSocket upgrade'));
|
||||||
|
}
|
||||||
|
if (!done.isCompleted) done.complete();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
final key = base64Encode(List<int>.generate(16, (index) => index));
|
||||||
|
socket.write(
|
||||||
|
'GET /ws HTTP/1.1\r\n'
|
||||||
|
'Host: 127.0.0.1:$port\r\n'
|
||||||
|
'Connection: Upgrade\r\n'
|
||||||
|
'Upgrade: websocket\r\n'
|
||||||
|
'Sec-WebSocket-Version: 13\r\n'
|
||||||
|
'Sec-WebSocket-Key: $key\r\n'
|
||||||
|
'\r\n',
|
||||||
|
);
|
||||||
|
await socket.flush();
|
||||||
|
await upgraded.future.timeout(_ioTimeout);
|
||||||
|
expect(responseHeaders.toString(), startsWith('HTTP/1.1 101'));
|
||||||
|
return _RawUpgradedSocket._(socket, subscription, done);
|
||||||
|
} catch (_) {
|
||||||
|
socket.destroy();
|
||||||
|
await subscription.cancel();
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final Socket socket;
|
||||||
|
final StreamSubscription<List<int>> _subscription;
|
||||||
|
final Completer<void> _done;
|
||||||
|
|
||||||
|
Future<void> dispose() async {
|
||||||
|
if (!_done.isCompleted) {
|
||||||
|
socket.destroy();
|
||||||
|
await _done.future.timeout(_ioTimeout);
|
||||||
|
}
|
||||||
|
await _subscription.cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ void main() {
|
|||||||
await prefs.setString('custom_relay_url', 'https://${canaries[2]}.invalid');
|
await prefs.setString('custom_relay_url', 'https://${canaries[2]}.invalid');
|
||||||
await prefs.setString('update_last_check_time', canaries[3]);
|
await prefs.setString('update_last_check_time', canaries[3]);
|
||||||
await prefs.setString('watch_together_recent_rooms', canaries[1]);
|
await prefs.setString('watch_together_recent_rooms', canaries[1]);
|
||||||
|
await prefs.setString('user_alice_watch_together_recent_rooms', canaries[1]);
|
||||||
await prefs.setString('future_runtime_key', 'unknown');
|
await prefs.setString('future_runtime_key', 'unknown');
|
||||||
|
|
||||||
final out = SettingsExportService.buildExportMap(prefs, currentUserUuid: 'alice');
|
final out = SettingsExportService.buildExportMap(prefs, currentUserUuid: 'alice');
|
||||||
@@ -105,6 +106,7 @@ void main() {
|
|||||||
expect(exported.keys, isNot(contains('custom_relay_url')));
|
expect(exported.keys, isNot(contains('custom_relay_url')));
|
||||||
expect(exported.keys, isNot(contains('update_last_check_time')));
|
expect(exported.keys, isNot(contains('update_last_check_time')));
|
||||||
expect(exported.keys, isNot(contains('watch_together_recent_rooms')));
|
expect(exported.keys, isNot(contains('watch_together_recent_rooms')));
|
||||||
|
expect(exported.keys, isNot(contains('user_alice_watch_together_recent_rooms')));
|
||||||
expect(exported.keys, isNot(contains('future_runtime_key')));
|
expect(exported.keys, isNot(contains('future_runtime_key')));
|
||||||
for (final canary in canaries) {
|
for (final canary in canaries) {
|
||||||
expect(encoded, isNot(contains(canary)));
|
expect(encoded, isNot(contains(canary)));
|
||||||
@@ -191,6 +193,8 @@ void main() {
|
|||||||
'custom_download_path': {'type': 'string', 'value': '/source/device/downloads'},
|
'custom_download_path': {'type': 'string', 'value': '/source/device/downloads'},
|
||||||
'custom_download_path_type': {'type': 'string', 'value': 'saf'},
|
'custom_download_path_type': {'type': 'string', 'value': 'saf'},
|
||||||
'crash_reporting': {'type': 'bool', 'value': true},
|
'crash_reporting': {'type': 'bool', 'value': true},
|
||||||
|
'custom_relay_url': {'type': 'string', 'value': 'https://relay.example.test'},
|
||||||
|
'watch_together_recent_rooms': {'type': 'string', 'value': '[]'},
|
||||||
'unknown_future_key': {'type': 'bool', 'value': true},
|
'unknown_future_key': {'type': 'bool', 'value': true},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -199,7 +203,7 @@ void main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(result.keysImported, 4);
|
expect(result.keysImported, 4);
|
||||||
expect(result.keysSkipped, 6);
|
expect(result.keysSkipped, 8);
|
||||||
expect(prefs.getBool('enable_hardware_decoding'), isTrue);
|
expect(prefs.getBool('enable_hardware_decoding'), isTrue);
|
||||||
expect(prefs.getDouble('default_playback_speed'), 1.0);
|
expect(prefs.getDouble('default_playback_speed'), 1.0);
|
||||||
expect(prefs.getStringList('user_alice_library_order'), ['movies']);
|
expect(prefs.getStringList('user_alice_library_order'), ['movies']);
|
||||||
@@ -210,6 +214,8 @@ void main() {
|
|||||||
expect(prefs.getString('custom_download_path'), '/target/device/downloads');
|
expect(prefs.getString('custom_download_path'), '/target/device/downloads');
|
||||||
expect(prefs.getString('custom_download_path_type'), 'file');
|
expect(prefs.getString('custom_download_path_type'), 'file');
|
||||||
expect(prefs.getBool('crash_reporting'), isFalse);
|
expect(prefs.getBool('crash_reporting'), isFalse);
|
||||||
|
expect(prefs.getString('custom_relay_url'), isNull);
|
||||||
|
expect(prefs.getString('user_alice_watch_together_recent_rooms'), isNull);
|
||||||
expect(prefs.getBool('unknown_future_key'), isNull);
|
expect(prefs.getBool('unknown_future_key'), isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:plezy/models/audio_quality_preset.dart';
|
import 'package:plezy/models/audio_quality_preset.dart';
|
||||||
@@ -226,6 +228,40 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('SettingsService Watch Together relay', () {
|
||||||
|
test('typed writes canonicalize valid bases and reject invalid replacement', () async {
|
||||||
|
final settings = await SettingsService.getInstance();
|
||||||
|
|
||||||
|
await settings.write(SettingsService.customRelayUrl, ' HTTPS://Relay.Example.Test/path/// ');
|
||||||
|
expect(settings.read(SettingsService.customRelayUrl), 'https://relay.example.test/path');
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
settings.write(SettingsService.customRelayUrl, 'ws://relay.example.test'),
|
||||||
|
throwsFormatException,
|
||||||
|
);
|
||||||
|
expect(settings.read(SettingsService.customRelayUrl), 'https://relay.example.test/path');
|
||||||
|
|
||||||
|
await settings.write(SettingsService.customRelayUrl, ' ');
|
||||||
|
expect(settings.read(SettingsService.customRelayUrl), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('startup canonicalizes valid history and removes invalid history', () async {
|
||||||
|
resetSharedPreferencesForTest(
|
||||||
|
initialAsync: {SettingsService.customRelayUrl.key: ' http://Relay.Example.Test:8080/prefix// '},
|
||||||
|
);
|
||||||
|
SettingsService.resetForTesting();
|
||||||
|
var settings = await SettingsService.getInstance();
|
||||||
|
expect(settings.read(SettingsService.customRelayUrl), 'http://relay.example.test:8080/prefix');
|
||||||
|
|
||||||
|
resetSharedPreferencesForTest(
|
||||||
|
initialAsync: {SettingsService.customRelayUrl.key: 'https://relay.example.test/path?wrong=route'},
|
||||||
|
);
|
||||||
|
SettingsService.resetForTesting();
|
||||||
|
settings = await SettingsService.getInstance();
|
||||||
|
expect(settings.read(SettingsService.customRelayUrl), isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
group('SettingsService listenables', () {
|
group('SettingsService listenables', () {
|
||||||
test('refreshListenables updates active prefs outside the resettable surface', () async {
|
test('refreshListenables updates active prefs outside the resettable surface', () async {
|
||||||
final settings = await SettingsService.getInstance();
|
final settings = await SettingsService.getInstance();
|
||||||
@@ -276,4 +312,48 @@ void main() {
|
|||||||
expect(settings.isLibraryAllowedForTracker(TrackerService.trakt, 'server:allowed'), isTrue);
|
expect(settings.isLibraryAllowedForTracker(TrackerService.trakt, 'server:allowed'), isTrue);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
group('BaseSharedPreferencesService initialization generations', () {
|
||||||
|
test('a reset-raced initialization resolves to the replacement backend', () async {
|
||||||
|
resetSharedPreferencesForTest(initialAsync: const {'generation_marker': 'old'});
|
||||||
|
final firstOnInit = Completer<void>();
|
||||||
|
final firstStarted = Completer<void>();
|
||||||
|
var constructions = 0;
|
||||||
|
|
||||||
|
final raced = BaseSharedPreferencesService.initializeInstance<_GenerationTestPreferences>(() {
|
||||||
|
final construction = constructions++;
|
||||||
|
return _GenerationTestPreferences(
|
||||||
|
construction,
|
||||||
|
onInitStarted: construction == 0 ? firstStarted : null,
|
||||||
|
onInitGate: construction == 0 ? firstOnInit : null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await firstStarted.future;
|
||||||
|
|
||||||
|
resetSharedPreferencesForTest(initialAsync: const {'generation_marker': 'new'});
|
||||||
|
final replacement = await BaseSharedPreferencesService.initializeInstance<_GenerationTestPreferences>(
|
||||||
|
() => _GenerationTestPreferences(constructions++),
|
||||||
|
);
|
||||||
|
firstOnInit.complete();
|
||||||
|
final recovered = await raced;
|
||||||
|
|
||||||
|
expect(recovered, same(replacement));
|
||||||
|
expect(recovered.construction, 1);
|
||||||
|
expect(recovered.readString('generation_marker'), 'new');
|
||||||
|
expect(constructions, 2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GenerationTestPreferences extends BaseSharedPreferencesService {
|
||||||
|
_GenerationTestPreferences(this.construction, {this.onInitStarted, this.onInitGate});
|
||||||
|
|
||||||
|
final int construction;
|
||||||
|
final Completer<void>? onInitStarted;
|
||||||
|
final Completer<void>? onInitGate;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> onInit() async {
|
||||||
|
onInitStarted?.complete();
|
||||||
|
await onInitGate?.future;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'dart:async';
|
|||||||
import 'package:plezy/mpv/mpv.dart';
|
import 'package:plezy/mpv/mpv.dart';
|
||||||
import 'package:plezy/watch_together/models/sync_message.dart';
|
import 'package:plezy/watch_together/models/sync_message.dart';
|
||||||
import 'package:plezy/watch_together/services/watch_together_peer_service.dart';
|
import 'package:plezy/watch_together/services/watch_together_peer_service.dart';
|
||||||
|
import 'package:plezy/watch_together/services/watch_together_relay_endpoint.dart';
|
||||||
|
|
||||||
/// Rich fake [Player] for Watch Together sync tests.
|
/// Rich fake [Player] for Watch Together sync tests.
|
||||||
///
|
///
|
||||||
@@ -196,6 +197,9 @@ class FakeRelayHub {
|
|||||||
final Map<String, HubPeerService> _peers = {};
|
final Map<String, HubPeerService> _peers = {};
|
||||||
|
|
||||||
HubPeerService register(String peerId) {
|
HubPeerService register(String peerId) {
|
||||||
|
if (_peers.containsKey(peerId)) {
|
||||||
|
throw StateError('Peer ID is already registered: $peerId');
|
||||||
|
}
|
||||||
final service = HubPeerService._(peerId, this);
|
final service = HubPeerService._(peerId, this);
|
||||||
for (final existing in _peers.values) {
|
for (final existing in _peers.values) {
|
||||||
existing._peerConnected.add(peerId);
|
existing._peerConnected.add(peerId);
|
||||||
@@ -235,7 +239,7 @@ class FakeRelayHub {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class HubPeerService extends WatchTogetherPeerService {
|
class HubPeerService extends WatchTogetherPeerService {
|
||||||
HubPeerService._(this.peerId, this._hub) : super(customBaseUrl: 'http://localhost');
|
HubPeerService._(this.peerId, this._hub) : super(endpoint: WatchTogetherRelayEndpoint.resolve('http://localhost'));
|
||||||
|
|
||||||
final String peerId;
|
final String peerId;
|
||||||
final FakeRelayHub _hub;
|
final FakeRelayHub _hub;
|
||||||
|
|||||||
@@ -16,9 +16,11 @@ class _Harness {
|
|||||||
FakeAsync async, {
|
FakeAsync async, {
|
||||||
ControlMode controlMode = ControlMode.hostOnly,
|
ControlMode controlMode = ControlMode.hostOnly,
|
||||||
HostCoordinatorCallbacks callbacks = const HostCoordinatorCallbacks(),
|
HostCoordinatorCallbacks callbacks = const HostCoordinatorCallbacks(),
|
||||||
|
Duration duration = const Duration(minutes: 45),
|
||||||
|
bool seekable = true,
|
||||||
}) {
|
}) {
|
||||||
int nowMs() => _epochMs + async.elapsed.inMilliseconds;
|
int nowMs() => _epochMs + async.elapsed.inMilliseconds;
|
||||||
player = FakeSyncPlayer(position: const Duration(minutes: 2));
|
player = FakeSyncPlayer(position: const Duration(minutes: 2), duration: duration, seekable: seekable);
|
||||||
coordinator = HostPlaybackCoordinator(
|
coordinator = HostPlaybackCoordinator(
|
||||||
myPeerId: 'host',
|
myPeerId: 'host',
|
||||||
controlMode: controlMode,
|
controlMode: controlMode,
|
||||||
@@ -446,6 +448,132 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('invalid remote seeks and rates are rejected without side effects', () {
|
||||||
|
fakeAsync((async) {
|
||||||
|
final actions = <(String, PlaybackActionHint)>[];
|
||||||
|
final h = _Harness(
|
||||||
|
async,
|
||||||
|
controlMode: ControlMode.anyone,
|
||||||
|
callbacks: HostCoordinatorCallbacks(onRemoteAction: (peer, hint) => actions.add((peer, hint))),
|
||||||
|
);
|
||||||
|
h.attachForMedia(async);
|
||||||
|
h.hostBecomesReady(async);
|
||||||
|
final durationMs = h.player.state.duration.inMilliseconds;
|
||||||
|
final broadcastsBefore = h.broadcasts.length;
|
||||||
|
final seqBefore = h.last.seq;
|
||||||
|
final anchorBefore = h.last.anchorPositionMs;
|
||||||
|
final rateBefore = h.last.rate;
|
||||||
|
h.player.commandLog.clear();
|
||||||
|
|
||||||
|
for (final targetMs in [-1, durationMs + 1]) {
|
||||||
|
h.coordinator.onControlRequest('guest', ControlRequest(kind: ControlRequestKind.seek, positionMs: targetMs));
|
||||||
|
}
|
||||||
|
for (final rate in [0.25 - 0.000001, 4.0 + 0.000001, double.nan, double.infinity, double.negativeInfinity]) {
|
||||||
|
h.coordinator.onControlRequest('guest', ControlRequest(kind: ControlRequestKind.rate, rate: rate));
|
||||||
|
}
|
||||||
|
async.flushMicrotasks();
|
||||||
|
|
||||||
|
expect(h.player.commandLog, isEmpty);
|
||||||
|
expect(actions, isEmpty);
|
||||||
|
expect(h.broadcasts, hasLength(broadcastsBefore));
|
||||||
|
expect(h.last.seq, seqBefore);
|
||||||
|
expect(h.last.anchorPositionMs, anchorBefore);
|
||||||
|
expect(h.last.rate, rateBefore);
|
||||||
|
h.dispose();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('inclusive remote seek and rate boundaries are applied exactly', () {
|
||||||
|
fakeAsync((async) {
|
||||||
|
final actions = <(String, PlaybackActionHint)>[];
|
||||||
|
final h = _Harness(
|
||||||
|
async,
|
||||||
|
controlMode: ControlMode.anyone,
|
||||||
|
callbacks: HostCoordinatorCallbacks(onRemoteAction: (peer, hint) => actions.add((peer, hint))),
|
||||||
|
);
|
||||||
|
h.attachForMedia(async);
|
||||||
|
h.hostBecomesReady(async);
|
||||||
|
final durationMs = h.player.state.duration.inMilliseconds;
|
||||||
|
final seqBefore = h.last.seq;
|
||||||
|
h.player.commandLog.clear();
|
||||||
|
|
||||||
|
for (final targetMs in [0, durationMs]) {
|
||||||
|
h.coordinator.onControlRequest('guest', ControlRequest(kind: ControlRequestKind.seek, positionMs: targetMs));
|
||||||
|
async.flushMicrotasks();
|
||||||
|
expect(h.last.anchorPositionMs, targetMs);
|
||||||
|
expect(h.last.actorPeerId, 'guest');
|
||||||
|
expect(h.last.actionHint, PlaybackActionHint.seek);
|
||||||
|
}
|
||||||
|
for (final rate in [0.25, 4.0]) {
|
||||||
|
h.coordinator.onControlRequest('guest', ControlRequest(kind: ControlRequestKind.rate, rate: rate));
|
||||||
|
async.flushMicrotasks();
|
||||||
|
expect(h.last.rate, rate);
|
||||||
|
expect(h.last.actorPeerId, 'guest');
|
||||||
|
expect(h.last.actionHint, PlaybackActionHint.rate);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(h.player.commandLog, ['seek:0', 'seek:$durationMs', 'rate:0.25', 'rate:4.0']);
|
||||||
|
expect(h.last.seq, seqBefore + 4);
|
||||||
|
expect(actions, [
|
||||||
|
('guest', PlaybackActionHint.seek),
|
||||||
|
('guest', PlaybackActionHint.seek),
|
||||||
|
('guest', PlaybackActionHint.rate),
|
||||||
|
('guest', PlaybackActionHint.rate),
|
||||||
|
]);
|
||||||
|
h.dispose();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('remote seeks require seekability and a positive known duration', () {
|
||||||
|
fakeAsync((async) {
|
||||||
|
for (final config in [
|
||||||
|
(seekable: false, duration: const Duration(minutes: 45)),
|
||||||
|
(seekable: true, duration: Duration.zero),
|
||||||
|
]) {
|
||||||
|
final actions = <(String, PlaybackActionHint)>[];
|
||||||
|
final h = _Harness(
|
||||||
|
async,
|
||||||
|
controlMode: ControlMode.anyone,
|
||||||
|
duration: config.duration,
|
||||||
|
seekable: config.seekable,
|
||||||
|
callbacks: HostCoordinatorCallbacks(onRemoteAction: (peer, hint) => actions.add((peer, hint))),
|
||||||
|
);
|
||||||
|
h.attachForMedia(async);
|
||||||
|
h.hostBecomesReady(async);
|
||||||
|
async.elapse(Duration(milliseconds: h.last.anchorHostTimeMs - (_epochMs + async.elapsed.inMilliseconds)));
|
||||||
|
h.player.commandLog.clear();
|
||||||
|
actions.clear();
|
||||||
|
final broadcastsBefore = h.broadcasts.length;
|
||||||
|
final seqBefore = h.last.seq;
|
||||||
|
|
||||||
|
h.coordinator.onControlRequest(
|
||||||
|
'guest',
|
||||||
|
const ControlRequest(kind: ControlRequestKind.seek, positionMs: 1000),
|
||||||
|
);
|
||||||
|
async.flushMicrotasks();
|
||||||
|
|
||||||
|
expect(h.player.commandLog, isEmpty);
|
||||||
|
expect(actions, isEmpty);
|
||||||
|
expect(h.broadcasts, hasLength(broadcastsBefore));
|
||||||
|
expect(h.last.seq, seqBefore);
|
||||||
|
|
||||||
|
h.coordinator.onControlRequest('guest', const ControlRequest(kind: ControlRequestKind.rate, rate: 0.25));
|
||||||
|
h.coordinator.onControlRequest('guest', const ControlRequest(kind: ControlRequestKind.pause));
|
||||||
|
h.coordinator.onControlRequest('guest', const ControlRequest(kind: ControlRequestKind.play));
|
||||||
|
async.flushMicrotasks();
|
||||||
|
async.elapse(Duration(milliseconds: h.last.anchorHostTimeMs - (_epochMs + async.elapsed.inMilliseconds)));
|
||||||
|
|
||||||
|
expect(h.player.commandLog, ['rate:0.25', 'pause', 'play']);
|
||||||
|
expect(actions, [
|
||||||
|
('guest', PlaybackActionHint.rate),
|
||||||
|
('guest', PlaybackActionHint.pause),
|
||||||
|
('guest', PlaybackActionHint.play),
|
||||||
|
]);
|
||||||
|
h.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('local seeks debounce into a single re-anchor broadcast', () {
|
test('local seeks debounce into a single re-anchor broadcast', () {
|
||||||
fakeAsync((async) {
|
fakeAsync((async) {
|
||||||
final h = _Harness(async);
|
final h = _Harness(async);
|
||||||
|
|||||||
@@ -112,10 +112,11 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('SyncMessage v2 envelope', () {
|
group('SyncMessage v3 envelope', () {
|
||||||
test('join carries the protocol version', () {
|
test('join carries sync protocol 3', () {
|
||||||
final join = SyncMessage.join(peerId: 'p', displayName: 'Name', isHost: false);
|
final join = SyncMessage.join(peerId: 'p', displayName: 'Name', isHost: false);
|
||||||
final decoded = SyncMessage.fromJson(join.toJson());
|
final decoded = SyncMessage.fromJson(join.toJson());
|
||||||
|
expect(decoded.version, 3);
|
||||||
expect(decoded.version, SyncMessage.protocolVersion);
|
expect(decoded.version, SyncMessage.protocolVersion);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -125,7 +126,7 @@ void main() {
|
|||||||
expect(decoded.peerId, 'p');
|
expect(decoded.peerId, 'p');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('copyWith preserves v2 payloads', () {
|
test('copyWith preserves v3 payloads', () {
|
||||||
final relabeled = SyncMessage.state(fullState).copyWith(peerId: 'relay-id');
|
final relabeled = SyncMessage.state(fullState).copyWith(peerId: 'relay-id');
|
||||||
expect(relabeled.state, fullState);
|
expect(relabeled.state, fullState);
|
||||||
expect(relabeled.peerId, 'relay-id');
|
expect(relabeled.peerId, 'relay-id');
|
||||||
|
|||||||
@@ -2,12 +2,6 @@ import 'package:flutter_test/flutter_test.dart';
|
|||||||
import 'package:plezy/watch_together/primitives.dart';
|
import 'package:plezy/watch_together/primitives.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
test('stored room codes preserve the established host peer wire format', () {
|
|
||||||
const persistedSessionId = 'Ab12z';
|
|
||||||
|
|
||||||
expect(watchTogetherHostPeerId(persistedSessionId), 'wt-AB12Z');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('orderedStringListsEqual preserves order and multiplicity', () {
|
test('orderedStringListsEqual preserves order and multiplicity', () {
|
||||||
expect(orderedStringListsEqual(const ['a', 'b'], const ['a', 'b']), isTrue);
|
expect(orderedStringListsEqual(const ['a', 'b'], const ['a', 'b']), isTrue);
|
||||||
expect(orderedStringListsEqual(const ['a'], const ['a', 'b']), isFalse);
|
expect(orderedStringListsEqual(const ['a'], const ['a', 'b']), isFalse);
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:plezy/services/settings_service.dart';
|
||||||
|
import 'package:plezy/watch_together/models/watch_session.dart';
|
||||||
|
import 'package:plezy/watch_together/services/recent_rooms_service.dart';
|
||||||
|
import 'package:plezy/watch_together/services/watch_together_relay_endpoint.dart';
|
||||||
|
|
||||||
|
import '../test_helpers/prefs.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late SettingsService settings;
|
||||||
|
final endpointA = WatchTogetherRelayEndpoint.resolve('https://relay-a.example.test/base/');
|
||||||
|
final endpointB = WatchTogetherRelayEndpoint.resolve('http://relay-b.example.test:8080');
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
resetSharedPreferencesForTest();
|
||||||
|
SettingsService.resetForTesting();
|
||||||
|
settings = await SettingsService.getInstance();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rooms are isolated by profile', () async {
|
||||||
|
await RecentRoomsService.addOrUpdateRoom(
|
||||||
|
'ROOM1',
|
||||||
|
profileId: 'profile-a',
|
||||||
|
endpoint: endpointA,
|
||||||
|
name: 'Profile A room',
|
||||||
|
controlMode: ControlMode.hostOnly,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(RecentRoomsService.getRecentRooms(profileId: 'profile-b', endpoint: endpointA), isEmpty);
|
||||||
|
|
||||||
|
await RecentRoomsService.addOrUpdateRoom(
|
||||||
|
'ROOM1',
|
||||||
|
profileId: 'profile-b',
|
||||||
|
endpoint: endpointA,
|
||||||
|
name: 'Profile B room',
|
||||||
|
controlMode: ControlMode.anyone,
|
||||||
|
);
|
||||||
|
|
||||||
|
final profileA = RecentRoomsService.getRecentRooms(profileId: 'profile-a', endpoint: endpointA);
|
||||||
|
final profileB = RecentRoomsService.getRecentRooms(profileId: 'profile-b', endpoint: endpointA);
|
||||||
|
expect(profileA.single.name, 'Profile A room');
|
||||||
|
expect(profileA.single.controlMode, ControlMode.hostOnly);
|
||||||
|
expect(profileB.single.name, 'Profile B room');
|
||||||
|
expect(profileB.single.controlMode, ControlMode.anyone);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('same code is independently mutable on different relay bases', () async {
|
||||||
|
await RecentRoomsService.addOrUpdateRoom(
|
||||||
|
'SAME1',
|
||||||
|
profileId: 'profile-a',
|
||||||
|
endpoint: endpointA,
|
||||||
|
name: 'Relay A',
|
||||||
|
controlMode: ControlMode.hostOnly,
|
||||||
|
);
|
||||||
|
await RecentRoomsService.addOrUpdateRoom(
|
||||||
|
'SAME1',
|
||||||
|
profileId: 'profile-a',
|
||||||
|
endpoint: endpointB,
|
||||||
|
name: 'Relay B',
|
||||||
|
controlMode: ControlMode.anyone,
|
||||||
|
);
|
||||||
|
|
||||||
|
await RecentRoomsService.renameRoom('SAME1', 'Renamed A', profileId: 'profile-a', endpoint: endpointA);
|
||||||
|
expect(RecentRoomsService.getRecentRooms(profileId: 'profile-a', endpoint: endpointA).single.name, 'Renamed A');
|
||||||
|
expect(RecentRoomsService.getRecentRooms(profileId: 'profile-a', endpoint: endpointB).single.name, 'Relay B');
|
||||||
|
|
||||||
|
await RecentRoomsService.removeRoom('SAME1', profileId: 'profile-a', endpoint: endpointA);
|
||||||
|
expect(RecentRoomsService.getRecentRooms(profileId: 'profile-a', endpoint: endpointA), isEmpty);
|
||||||
|
expect(RecentRoomsService.getRecentRooms(profileId: 'profile-a', endpoint: endpointB).single.code, 'SAME1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('profile history remains bounded across relay scopes and deduplicates tuples', () async {
|
||||||
|
for (var index = 0; index < 21; index++) {
|
||||||
|
await RecentRoomsService.addOrUpdateRoom(
|
||||||
|
'R${index.toString().padLeft(4, '0')}',
|
||||||
|
profileId: 'profile-a',
|
||||||
|
endpoint: index.isEven ? endpointA : endpointB,
|
||||||
|
);
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
final raw = settings.read(SettingsService.recentRoomsForProfile('profile-a'));
|
||||||
|
final rows = jsonDecode(raw!) as List<dynamic>;
|
||||||
|
expect(rows, hasLength(20));
|
||||||
|
expect(rows.map((row) => (row as Map<String, dynamic>)['code']), isNot(contains('R0000')));
|
||||||
|
|
||||||
|
await RecentRoomsService.addOrUpdateRoom('R0020', profileId: 'profile-a', endpoint: endpointA, name: 'Updated');
|
||||||
|
final updatedRows = jsonDecode(settings.read(SettingsService.recentRoomsForProfile('profile-a'))!) as List<dynamic>;
|
||||||
|
expect(updatedRows, hasLength(20));
|
||||||
|
expect(
|
||||||
|
updatedRows.where(
|
||||||
|
(row) =>
|
||||||
|
(row as Map<String, dynamic>)['code'] == 'R0020' &&
|
||||||
|
row['relayScope'] == RecentRoomsService.relayScopeFor(endpointA),
|
||||||
|
),
|
||||||
|
hasLength(1),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('startup drops unattributable legacy history', () async {
|
||||||
|
SettingsService.resetForTesting();
|
||||||
|
resetSharedPreferencesForTest(
|
||||||
|
initialAsync: {
|
||||||
|
'watch_together_recent_rooms': jsonEncode([
|
||||||
|
{'code': 'OLD01', 'lastUsed': 1},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final initialized = await SettingsService.getInstance();
|
||||||
|
expect(initialized.prefs.containsKey('watch_together_recent_rooms'), isFalse);
|
||||||
|
expect(RecentRoomsService.getRecentRooms(profileId: 'profile-a', endpoint: endpointA), isEmpty);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -209,6 +209,74 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('anyone-mode: invalid controls are rejected and the queue continues', () {
|
||||||
|
fakeAsync((async) {
|
||||||
|
final room = _Room(async, controlMode: ControlMode.anyone);
|
||||||
|
final actions = <(String, PlaybackActionHint)>[];
|
||||||
|
room.host.onRemoteAction = (peer, hint) => actions.add((peer, hint));
|
||||||
|
room.hostStartsMedia();
|
||||||
|
room.guestJoinsMedia();
|
||||||
|
room.bothBecomeReady();
|
||||||
|
final delay = room.lastHostState().anchorHostTimeMs - room.nowMs();
|
||||||
|
async.elapse(Duration(milliseconds: delay + 100));
|
||||||
|
room.hostPlayer.commandLog.clear();
|
||||||
|
final statesBefore = room.hostService.outgoingLog
|
||||||
|
.where((message) => message.type == SyncMessageType.state)
|
||||||
|
.length;
|
||||||
|
final stateBefore = room.lastHostState();
|
||||||
|
|
||||||
|
SyncMessage wireControl(ControlRequest request) {
|
||||||
|
return SyncMessage.fromJson(SyncMessage.control(request, peerId: 'forged-peer').toJson());
|
||||||
|
}
|
||||||
|
|
||||||
|
room.guestService.sendTo(
|
||||||
|
'host',
|
||||||
|
wireControl(
|
||||||
|
ControlRequest(kind: ControlRequestKind.seek, positionMs: room.hostPlayer.state.duration.inMilliseconds + 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
room.guestService.sendTo(
|
||||||
|
'host',
|
||||||
|
wireControl(const ControlRequest(kind: ControlRequestKind.rate, rate: 4.000001)),
|
||||||
|
);
|
||||||
|
async.flushMicrotasks();
|
||||||
|
|
||||||
|
expect(room.hostPlayer.commandLog, isEmpty);
|
||||||
|
expect(actions, isEmpty);
|
||||||
|
expect(
|
||||||
|
room.hostService.outgoingLog.where((message) => message.type == SyncMessageType.state),
|
||||||
|
hasLength(statesBefore),
|
||||||
|
);
|
||||||
|
expect(room.lastHostState().seq, stateBefore.seq);
|
||||||
|
expect(room.lastHostState().anchorPositionMs, stateBefore.anchorPositionMs);
|
||||||
|
expect(room.lastHostState().rate, stateBefore.rate);
|
||||||
|
|
||||||
|
room.guestService.sendTo(
|
||||||
|
'host',
|
||||||
|
wireControl(const ControlRequest(kind: ControlRequestKind.seek, positionMs: 600000)),
|
||||||
|
);
|
||||||
|
async.flushMicrotasks();
|
||||||
|
room.guestService.sendTo('host', wireControl(const ControlRequest(kind: ControlRequestKind.rate, rate: 0.25)));
|
||||||
|
async.flushMicrotasks();
|
||||||
|
|
||||||
|
expect(room.hostPlayer.commandLog, ['seek:600000', 'rate:0.25']);
|
||||||
|
expect(actions, [('guest', PlaybackActionHint.seek), ('guest', PlaybackActionHint.rate)]);
|
||||||
|
final acceptedStates = room.hostService.outgoingLog
|
||||||
|
.where((message) => message.type == SyncMessageType.state)
|
||||||
|
.skip(statesBefore)
|
||||||
|
.map((message) => message.state!)
|
||||||
|
.toList();
|
||||||
|
expect(acceptedStates, hasLength(2));
|
||||||
|
expect(acceptedStates[0].anchorPositionMs, 600000);
|
||||||
|
expect(acceptedStates[0].actionHint, PlaybackActionHint.seek);
|
||||||
|
expect(acceptedStates[0].actorPeerId, 'guest');
|
||||||
|
expect(acceptedStates[1].rate, 0.25);
|
||||||
|
expect(acceptedStates[1].actionHint, PlaybackActionHint.rate);
|
||||||
|
expect(acceptedStates[1].actorPeerId, 'guest');
|
||||||
|
room.dispose();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('guest controller starts clock-sync pings automatically', () {
|
test('guest controller starts clock-sync pings automatically', () {
|
||||||
fakeAsync((async) {
|
fakeAsync((async) {
|
||||||
final room = _Room(async);
|
final room = _Room(async);
|
||||||
@@ -221,15 +289,14 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('v1 peers are flagged and never gate the start', () {
|
test('v2 peers are flagged and never gate the start', () {
|
||||||
fakeAsync((async) {
|
fakeAsync((async) {
|
||||||
final needsUpdate = <String>[];
|
final needsUpdate = <String>[];
|
||||||
final room = _Room(async);
|
final room = _Room(async);
|
||||||
room.host.onPeerNeedsUpdate = needsUpdate.add;
|
room.host.onPeerNeedsUpdate = needsUpdate.add;
|
||||||
|
|
||||||
// A legacy client joins on its own connection: its join message has no
|
// A sync-protocol-2 client joins on its own connection. The relay
|
||||||
// version field (the relay stamps the sender id, so it must really
|
// stamps the sender ID, so it must really connect as itself.
|
||||||
// connect as itself — peerId spoofing is rewritten).
|
|
||||||
final legacyService = room.hub.register('legacy');
|
final legacyService = room.hub.register('legacy');
|
||||||
legacyService.sendTo(
|
legacyService.sendTo(
|
||||||
'host',
|
'host',
|
||||||
@@ -239,6 +306,7 @@ void main() {
|
|||||||
peerId: 'legacy',
|
peerId: 'legacy',
|
||||||
displayName: 'Old App',
|
displayName: 'Old App',
|
||||||
isHost: false,
|
isHost: false,
|
||||||
|
version: 2,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
async.flushMicrotasks();
|
async.flushMicrotasks();
|
||||||
@@ -253,6 +321,34 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('versionless legacy peers are flagged and never gate the start', () {
|
||||||
|
fakeAsync((async) {
|
||||||
|
final needsUpdate = <String>[];
|
||||||
|
final room = _Room(async);
|
||||||
|
room.host.onPeerNeedsUpdate = needsUpdate.add;
|
||||||
|
|
||||||
|
final versionlessService = room.hub.register('versionless');
|
||||||
|
versionlessService.sendTo(
|
||||||
|
'host',
|
||||||
|
SyncMessage(
|
||||||
|
type: SyncMessageType.join,
|
||||||
|
timestamp: room.nowMs(),
|
||||||
|
peerId: 'versionless',
|
||||||
|
displayName: 'Old App',
|
||||||
|
isHost: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
async.flushMicrotasks();
|
||||||
|
expect(needsUpdate, ['versionless']);
|
||||||
|
|
||||||
|
room.hostStartsMedia();
|
||||||
|
room.guestJoinsMedia();
|
||||||
|
room.bothBecomeReady();
|
||||||
|
expect(room.lastHostState().phase, PlaybackPhase.playing);
|
||||||
|
room.dispose();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('guest reconnect re-requests state and the host answers directly', () {
|
test('guest reconnect re-requests state and the host answers directly', () {
|
||||||
fakeAsync((async) {
|
fakeAsync((async) {
|
||||||
final room = _Room(async);
|
final room = _Room(async);
|
||||||
@@ -274,6 +370,46 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('only relay-stamped state from the declared host reaches guest reconciliation', () {
|
||||||
|
fakeAsync((async) {
|
||||||
|
final room = _Room(async);
|
||||||
|
final mediaDispatches = <String>[];
|
||||||
|
room.guest.onMediaStateReceived = (ratingKey, serverId, title) => mediaDispatches.add(ratingKey);
|
||||||
|
const state = PlaybackState(
|
||||||
|
seq: 10,
|
||||||
|
ratingKey: 'relay-authority',
|
||||||
|
serverId: 'srv',
|
||||||
|
mediaTitle: 'Authorized',
|
||||||
|
phase: PlaybackPhase.loading,
|
||||||
|
anchorPositionMs: 0,
|
||||||
|
anchorHostTimeMs: _epochMs,
|
||||||
|
rate: 1,
|
||||||
|
controlMode: ControlMode.hostOnly,
|
||||||
|
);
|
||||||
|
final unprivileged = room.hub.register('unprivileged');
|
||||||
|
|
||||||
|
// The fake relay overwrites the payload claim with the connection's
|
||||||
|
// routing ID, just like the production relay envelope parser.
|
||||||
|
unprivileged.broadcast(SyncMessage.state(state, peerId: 'host'));
|
||||||
|
async.flushMicrotasks();
|
||||||
|
expect(mediaDispatches, isEmpty);
|
||||||
|
|
||||||
|
room.hostService.broadcast(SyncMessage.state(state, peerId: 'host'));
|
||||||
|
async.flushMicrotasks();
|
||||||
|
expect(mediaDispatches, ['relay-authority']);
|
||||||
|
room.dispose();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fake relay rejects duplicate routing IDs instead of replacing authority', () async {
|
||||||
|
final hub = FakeRelayHub();
|
||||||
|
hub.register('reserved');
|
||||||
|
|
||||||
|
expect(() => hub.register('reserved'), throwsStateError);
|
||||||
|
|
||||||
|
await hub.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
group('hostExitedPlayer routing', () {
|
group('hostExitedPlayer routing', () {
|
||||||
test('rides the ordered queue: never overtakes states sent before it', () {
|
test('rides the ordered queue: never overtakes states sent before it', () {
|
||||||
fakeAsync((async) {
|
fakeAsync((async) {
|
||||||
|
|||||||
@@ -55,6 +55,20 @@ void main() {
|
|||||||
expect(harness.onLeaveSessionCalls, 0);
|
expect(harness.onLeaveSessionCalls, 0);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
testWidgets('best-effort leave failure is handled by the overlay', (tester) async {
|
||||||
|
final harness = _OverlayHarness(isHost: false, leaveError: StateError('release failed'));
|
||||||
|
addTearDown(harness.dispose);
|
||||||
|
await tester.pumpWidget(harness.build());
|
||||||
|
|
||||||
|
await _openLeaveConfirmation(tester, harness);
|
||||||
|
await tester.tap(find.text(t.watchTogether.leave));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(harness.provider.leaveCalls, 1);
|
||||||
|
expect(harness.onLeaveSessionCalls, 1);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _openLeaveConfirmation(WidgetTester tester, _OverlayHarness harness) async {
|
Future<void> _openLeaveConfirmation(WidgetTester tester, _OverlayHarness harness) async {
|
||||||
@@ -69,7 +83,8 @@ Future<void> _openLeaveConfirmation(WidgetTester tester, _OverlayHarness harness
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _OverlayHarness {
|
class _OverlayHarness {
|
||||||
_OverlayHarness({required bool isHost}) : provider = _FakeWatchTogetherProvider(isHostValue: isHost);
|
_OverlayHarness({required bool isHost, Object? leaveError})
|
||||||
|
: provider = _FakeWatchTogetherProvider(isHostValue: isHost, leaveError: leaveError);
|
||||||
|
|
||||||
static const indicatorKey = Key('watch-together-session-indicator');
|
static const indicatorKey = Key('watch-together-session-indicator');
|
||||||
|
|
||||||
@@ -101,9 +116,10 @@ class _OverlayHarness {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _FakeWatchTogetherProvider extends WatchTogetherProvider {
|
class _FakeWatchTogetherProvider extends WatchTogetherProvider {
|
||||||
_FakeWatchTogetherProvider({required this.isHostValue});
|
_FakeWatchTogetherProvider({required this.isHostValue, this.leaveError});
|
||||||
|
|
||||||
final bool isHostValue;
|
final bool isHostValue;
|
||||||
|
final Object? leaveError;
|
||||||
var leaveCalls = 0;
|
var leaveCalls = 0;
|
||||||
var _isDisposing = false;
|
var _isDisposing = false;
|
||||||
|
|
||||||
@@ -127,6 +143,8 @@ class _FakeWatchTogetherProvider extends WatchTogetherProvider {
|
|||||||
@override
|
@override
|
||||||
Future<void> leaveSession() async {
|
Future<void> leaveSession() async {
|
||||||
if (!_isDisposing) leaveCalls++;
|
if (!_isDisposing) leaveCalls++;
|
||||||
|
final error = leaveError;
|
||||||
|
if (error != null) throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,256 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:fake_async/fake_async.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:plezy/media/ids.dart';
|
import 'package:plezy/media/ids.dart';
|
||||||
|
import 'package:plezy/watch_together/services/watch_together_relay_endpoint.dart';
|
||||||
|
import 'package:plezy/watch_together/models/sync_message.dart';
|
||||||
import 'package:plezy/watch_together/models/watch_session.dart';
|
import 'package:plezy/watch_together/models/watch_session.dart';
|
||||||
import 'package:plezy/watch_together/providers/watch_together_provider.dart';
|
import 'package:plezy/watch_together/providers/watch_together_provider.dart';
|
||||||
|
import 'package:plezy/watch_together/services/watch_together_peer_service.dart';
|
||||||
|
import 'package:plezy/watch_together/services/relay_protocol.g.dart';
|
||||||
|
|
||||||
import '../test_helpers/prefs.dart';
|
import '../test_helpers/prefs.dart';
|
||||||
|
|
||||||
|
class _FakeWatchTogetherPeerService extends WatchTogetherPeerService {
|
||||||
|
_FakeWatchTogetherPeerService(
|
||||||
|
this.sequence, {
|
||||||
|
required bool hostInitiallyConnected,
|
||||||
|
required this.rejectDisconnectedTargets,
|
||||||
|
this.releaseError,
|
||||||
|
this.releaseBarrier,
|
||||||
|
}) : _hostConnected = hostInitiallyConnected;
|
||||||
|
|
||||||
|
final int sequence;
|
||||||
|
final bool rejectDisconnectedTargets;
|
||||||
|
final Object? releaseError;
|
||||||
|
final Future<void>? releaseBarrier;
|
||||||
|
final _peerConnectedController = StreamController<String>.broadcast();
|
||||||
|
final _peerDisconnectedController = StreamController<String>.broadcast();
|
||||||
|
final _messageController = StreamController<SyncMessage>.broadcast();
|
||||||
|
final _errorController = StreamController<PeerError>.broadcast();
|
||||||
|
final _sessionEndedController = StreamController<void>.broadcast();
|
||||||
|
final List<SyncMessage> broadcasts = [];
|
||||||
|
final List<(String, SyncMessage)> directMessages = [];
|
||||||
|
|
||||||
|
String? _sessionId;
|
||||||
|
String? _myPeerId;
|
||||||
|
String? _hostPeerId;
|
||||||
|
bool _isHost = false;
|
||||||
|
bool _disposed = false;
|
||||||
|
bool _didDisconnect = false;
|
||||||
|
int releaseCalls = 0;
|
||||||
|
bool _hostConnected;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<String> get onPeerConnected => _peerConnectedController.stream;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<String> get onPeerDisconnected => _peerDisconnectedController.stream;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<SyncMessage> get onMessageReceived => _messageController.stream;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<PeerError> get onError => _errorController.stream;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<void> get onSessionEnded => _sessionEndedController.stream;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? get sessionId => _sessionId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? get myPeerId => _myPeerId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? get hostPeerId => _hostPeerId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get isHost => _isHost;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<String> get connectedPeers => !_isHost && _sessionId != null && _hostConnected ? ['wt-$_sessionId'] : const [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String> createSession({String? sessionId}) {
|
||||||
|
_sessionId = (sessionId ?? 'ROOM$sequence').toUpperCase();
|
||||||
|
_myPeerId = 'wt-$_sessionId';
|
||||||
|
_hostPeerId = _myPeerId;
|
||||||
|
_isHost = true;
|
||||||
|
return Future.value(_sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> joinSession(String sessionId) {
|
||||||
|
_sessionId = sessionId.toUpperCase();
|
||||||
|
_myPeerId = 'guest-$sequence';
|
||||||
|
_hostPeerId = 'wt-$_sessionId';
|
||||||
|
_isHost = false;
|
||||||
|
return Future.value();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void broadcast(SyncMessage message) {
|
||||||
|
broadcasts.add(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void sendTo(String peerId, SyncMessage message) {
|
||||||
|
if (rejectDisconnectedTargets && !connectedPeers.contains(peerId)) {
|
||||||
|
_errorController.add(
|
||||||
|
const PeerError(type: PeerErrorType.serverError, message: 'Peer is not in the room', serverCode: 'not_in_room'),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
directMessages.add((peerId, message));
|
||||||
|
}
|
||||||
|
|
||||||
|
void emitError(PeerError error) => _errorController.add(error);
|
||||||
|
void emitPeerConnected(String peerId) {
|
||||||
|
if (peerId == _hostPeerId) _hostConnected = true;
|
||||||
|
_peerConnectedController.add(peerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
void emitPeerDisconnected(String peerId) {
|
||||||
|
if (peerId == _hostPeerId) _hostConnected = false;
|
||||||
|
_peerDisconnectedController.add(peerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
void emitSessionEnded() => _sessionEndedController.add(null);
|
||||||
|
void emitMessage(SyncMessage message) => _messageController.add(message);
|
||||||
|
|
||||||
|
bool get hasRelayListeners =>
|
||||||
|
_peerConnectedController.hasListener ||
|
||||||
|
_peerDisconnectedController.hasListener ||
|
||||||
|
_messageController.hasListener ||
|
||||||
|
_errorController.hasListener ||
|
||||||
|
_sessionEndedController.hasListener;
|
||||||
|
|
||||||
|
bool get isDisposed => _disposed;
|
||||||
|
bool get didDisconnect => _didDisconnect;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> releaseSession() async {
|
||||||
|
releaseCalls++;
|
||||||
|
final barrier = releaseBarrier;
|
||||||
|
if (barrier != null) await barrier;
|
||||||
|
final error = releaseError;
|
||||||
|
if (error != null) throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
void reconnect() => onReconnected?.call();
|
||||||
|
@override
|
||||||
|
Future<void> disconnect() {
|
||||||
|
_didDisconnect = true;
|
||||||
|
_sessionId = null;
|
||||||
|
_myPeerId = null;
|
||||||
|
_hostPeerId = null;
|
||||||
|
_isHost = false;
|
||||||
|
_hostConnected = false;
|
||||||
|
return Future.value();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
if (_disposed) return;
|
||||||
|
_disposed = true;
|
||||||
|
_peerConnectedController.close();
|
||||||
|
_peerDisconnectedController.close();
|
||||||
|
_messageController.close();
|
||||||
|
_errorController.close();
|
||||||
|
_sessionEndedController.close();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FakePeerServiceFactory {
|
||||||
|
_FakePeerServiceFactory({
|
||||||
|
this.hostInitiallyConnected = true,
|
||||||
|
this.rejectDisconnectedTargets = false,
|
||||||
|
this.releaseError,
|
||||||
|
this.releaseBarrier,
|
||||||
|
});
|
||||||
|
|
||||||
|
final bool hostInitiallyConnected;
|
||||||
|
final bool rejectDisconnectedTargets;
|
||||||
|
final Object? releaseError;
|
||||||
|
final Future<void>? releaseBarrier;
|
||||||
|
final List<_FakeWatchTogetherPeerService> services = [];
|
||||||
|
WatchTogetherPeerService call({WatchTogetherRelayEndpoint? endpoint}) {
|
||||||
|
final service = _FakeWatchTogetherPeerService(
|
||||||
|
services.length + 1,
|
||||||
|
hostInitiallyConnected: hostInitiallyConnected,
|
||||||
|
rejectDisconnectedTargets: rejectDisconnectedTargets,
|
||||||
|
releaseError: releaseError,
|
||||||
|
releaseBarrier: releaseBarrier,
|
||||||
|
);
|
||||||
|
services.add(service);
|
||||||
|
return service;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PeerError _transportError([String message = 'WebSocket error: connection reset']) {
|
||||||
|
return PeerError(type: PeerErrorType.serverError, message: message, originalError: StateError('connection reset'));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _flushProviderEvents() async {
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
}
|
||||||
|
|
||||||
|
const _providerHostId = 'relay-authoritative-host';
|
||||||
|
|
||||||
|
typedef _ProviderRelayHandler = void Function(WebSocket socket, Map<String, dynamic> message);
|
||||||
|
|
||||||
|
class _ProviderRelay {
|
||||||
|
_ProviderRelay._(this._server);
|
||||||
|
|
||||||
|
final HttpServer _server;
|
||||||
|
final List<WebSocket> _sockets = [];
|
||||||
|
final List<Map<String, dynamic>> messages = [];
|
||||||
|
|
||||||
|
String get baseUrl => 'http://${_server.address.address}:${_server.port}';
|
||||||
|
|
||||||
|
static Future<_ProviderRelay> start(_ProviderRelayHandler handler) async {
|
||||||
|
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||||
|
final relay = _ProviderRelay._(server);
|
||||||
|
server.listen((request) async {
|
||||||
|
if (request.uri.path != '/relay') {
|
||||||
|
request.response.statusCode = HttpStatus.notFound;
|
||||||
|
await request.response.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final socket = await WebSocketTransformer.upgrade(request);
|
||||||
|
relay._sockets.add(socket);
|
||||||
|
socket.listen((data) {
|
||||||
|
final message = jsonDecode(data as String) as Map<String, dynamic>;
|
||||||
|
relay.messages.add(message);
|
||||||
|
handler(socket, message);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return relay;
|
||||||
|
}
|
||||||
|
|
||||||
|
void send(WebSocket socket, Map<String, dynamic> message) => socket.add(jsonEncode(message));
|
||||||
|
|
||||||
|
Future<void> close() async {
|
||||||
|
for (final socket in _sockets) {
|
||||||
|
await socket.close();
|
||||||
|
}
|
||||||
|
await _server.close(force: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
test('generated relay versions match the protocol specification', () {
|
||||||
|
final spec = (jsonDecode(File('relay_protocol.json').readAsStringSync()) as Map).cast<String, dynamic>();
|
||||||
|
expect(RelayProtocol.protocolVersion, spec['protocolVersion']);
|
||||||
|
expect(RelayProtocol.legacyProtocolVersion, spec['legacyProtocolVersion']);
|
||||||
|
});
|
||||||
|
|
||||||
TestWidgetsFlutterBinding.ensureInitialized();
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
setUp(() {
|
setUp(() {
|
||||||
@@ -210,6 +453,389 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('WatchTogetherProvider — reconnect recovery', () {
|
||||||
|
test('restores only the matching host transport error and preserves session fields', () async {
|
||||||
|
final factory = _FakePeerServiceFactory();
|
||||||
|
final provider = WatchTogetherProvider(peerServiceFactory: factory.call);
|
||||||
|
final observedStates = <SessionState?>[];
|
||||||
|
provider.addListener(() => observedStates.add(provider.session?.state));
|
||||||
|
|
||||||
|
await provider.createSession(
|
||||||
|
controlMode: ControlMode.anyone,
|
||||||
|
relayEndpoint: WatchTogetherRelayEndpoint.defaultEndpoint,
|
||||||
|
displayName: 'Host',
|
||||||
|
sessionId: 'room1',
|
||||||
|
mediaRatingKey: 'rating-1',
|
||||||
|
mediaServerId: 'server-1',
|
||||||
|
mediaTitle: 'Episode 1',
|
||||||
|
);
|
||||||
|
await _flushProviderEvents();
|
||||||
|
final established = provider.session!;
|
||||||
|
final service = factory.services.single;
|
||||||
|
observedStates.clear();
|
||||||
|
service.broadcasts.clear();
|
||||||
|
|
||||||
|
service.emitError(_transportError());
|
||||||
|
await _flushProviderEvents();
|
||||||
|
expect(provider.session?.state, SessionState.error);
|
||||||
|
expect(provider.isConnected, isFalse);
|
||||||
|
|
||||||
|
service.reconnect();
|
||||||
|
await _flushProviderEvents();
|
||||||
|
|
||||||
|
expect(observedStates, [SessionState.error, SessionState.connected]);
|
||||||
|
expect(provider.isConnected, isTrue);
|
||||||
|
expect(provider.session?.errorMessage, isNull);
|
||||||
|
expect(provider.session?.sessionId, established.sessionId);
|
||||||
|
expect(provider.session?.role, established.role);
|
||||||
|
expect(provider.session?.controlMode, established.controlMode);
|
||||||
|
expect(provider.session?.mediaRatingKey, established.mediaRatingKey);
|
||||||
|
expect(provider.session?.mediaServerId, established.mediaServerId);
|
||||||
|
expect(provider.session?.mediaTitle, established.mediaTitle);
|
||||||
|
expect(service.broadcasts.where((message) => message.type == SyncMessageType.join), hasLength(1));
|
||||||
|
|
||||||
|
await provider.leaveSession();
|
||||||
|
provider.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('guest waits for a reconnecting declared host before requesting state', () async {
|
||||||
|
final factory = _FakePeerServiceFactory(hostInitiallyConnected: false, rejectDisconnectedTargets: true);
|
||||||
|
final provider = WatchTogetherProvider(peerServiceFactory: factory.call);
|
||||||
|
|
||||||
|
await provider.joinSession(
|
||||||
|
'late1',
|
||||||
|
relayEndpoint: WatchTogetherRelayEndpoint.defaultEndpoint,
|
||||||
|
displayName: 'Guest',
|
||||||
|
);
|
||||||
|
await _flushProviderEvents();
|
||||||
|
final service = factory.services.single;
|
||||||
|
final hostPeerId = provider.session!.hostPeerId!;
|
||||||
|
|
||||||
|
expect(provider.session?.state, SessionState.connected);
|
||||||
|
expect(service.connectedPeers, isEmpty);
|
||||||
|
expect(service.directMessages.where((entry) => entry.$2.type == SyncMessageType.requestState), isEmpty);
|
||||||
|
|
||||||
|
service.emitPeerConnected(hostPeerId);
|
||||||
|
await _flushProviderEvents();
|
||||||
|
|
||||||
|
expect(service.connectedPeers, [hostPeerId]);
|
||||||
|
expect(
|
||||||
|
service.directMessages.where(
|
||||||
|
(entry) => entry.$1 == hostPeerId && entry.$2.type == SyncMessageType.requestState,
|
||||||
|
),
|
||||||
|
hasLength(1),
|
||||||
|
);
|
||||||
|
expect(provider.session?.state, SessionState.connected);
|
||||||
|
|
||||||
|
await provider.leaveSession();
|
||||||
|
provider.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('guest recovery re-announces and re-requests authoritative state once', () async {
|
||||||
|
final factory = _FakePeerServiceFactory();
|
||||||
|
final provider = WatchTogetherProvider(peerServiceFactory: factory.call);
|
||||||
|
await provider.joinSession(
|
||||||
|
'room2',
|
||||||
|
relayEndpoint: WatchTogetherRelayEndpoint.defaultEndpoint,
|
||||||
|
displayName: 'Guest',
|
||||||
|
);
|
||||||
|
await _flushProviderEvents();
|
||||||
|
final service = factory.services.single;
|
||||||
|
service.broadcasts.clear();
|
||||||
|
service.directMessages.clear();
|
||||||
|
|
||||||
|
service.emitError(_transportError());
|
||||||
|
await _flushProviderEvents();
|
||||||
|
service.reconnect();
|
||||||
|
await _flushProviderEvents();
|
||||||
|
|
||||||
|
expect(provider.session?.state, SessionState.connected);
|
||||||
|
expect(service.broadcasts.where((message) => message.type == SyncMessageType.join), hasLength(1));
|
||||||
|
expect(service.directMessages.where((entry) => entry.$2.type == SyncMessageType.requestState), hasLength(1));
|
||||||
|
|
||||||
|
await provider.leaveSession();
|
||||||
|
provider.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('relay errors remain terminal when the current service reconnects', () async {
|
||||||
|
final factory = _FakePeerServiceFactory();
|
||||||
|
final provider = WatchTogetherProvider(peerServiceFactory: factory.call);
|
||||||
|
final observedStates = <SessionState?>[];
|
||||||
|
provider.addListener(() => observedStates.add(provider.session?.state));
|
||||||
|
await provider.createSession(
|
||||||
|
controlMode: ControlMode.hostOnly,
|
||||||
|
relayEndpoint: WatchTogetherRelayEndpoint.defaultEndpoint,
|
||||||
|
sessionId: 'room3',
|
||||||
|
);
|
||||||
|
await _flushProviderEvents();
|
||||||
|
final service = factory.services.single;
|
||||||
|
observedStates.clear();
|
||||||
|
|
||||||
|
service.emitError(
|
||||||
|
const PeerError(type: PeerErrorType.serverError, message: 'Room was rejected', serverCode: 'room_rejected'),
|
||||||
|
);
|
||||||
|
await _flushProviderEvents();
|
||||||
|
service.reconnect();
|
||||||
|
await _flushProviderEvents();
|
||||||
|
|
||||||
|
expect(observedStates, [SessionState.error]);
|
||||||
|
expect(provider.session?.state, SessionState.error);
|
||||||
|
expect(provider.session?.errorMessage, 'Room was rejected');
|
||||||
|
expect(provider.isConnected, isFalse);
|
||||||
|
|
||||||
|
await provider.leaveSession();
|
||||||
|
provider.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('host-loss expiry supersedes a recoverable guest transport error', () {
|
||||||
|
fakeAsync((async) {
|
||||||
|
final factory = _FakePeerServiceFactory();
|
||||||
|
final provider = WatchTogetherProvider(peerServiceFactory: factory.call);
|
||||||
|
var joined = false;
|
||||||
|
unawaited(
|
||||||
|
provider
|
||||||
|
.joinSession('room4', relayEndpoint: WatchTogetherRelayEndpoint.defaultEndpoint, displayName: 'Guest')
|
||||||
|
.then((_) => joined = true),
|
||||||
|
);
|
||||||
|
async.flushMicrotasks();
|
||||||
|
expect(joined, isTrue);
|
||||||
|
final service = factory.services.single;
|
||||||
|
final hostPeerId = provider.session!.hostPeerId!;
|
||||||
|
|
||||||
|
service.emitError(_transportError());
|
||||||
|
async.flushMicrotasks();
|
||||||
|
expect(provider.session?.state, SessionState.error);
|
||||||
|
|
||||||
|
service.emitPeerDisconnected(hostPeerId);
|
||||||
|
async.flushMicrotasks();
|
||||||
|
expect(provider.isWaitingForHostReconnect, isTrue);
|
||||||
|
async.elapse(const Duration(seconds: 15));
|
||||||
|
async.flushMicrotasks();
|
||||||
|
expect(provider.session?.errorMessage, 'Host left the session');
|
||||||
|
|
||||||
|
service.reconnect();
|
||||||
|
async.flushMicrotasks();
|
||||||
|
expect(provider.session?.state, SessionState.error);
|
||||||
|
expect(provider.session?.errorMessage, 'Host left the session');
|
||||||
|
|
||||||
|
unawaited(provider.leaveSession());
|
||||||
|
async.flushMicrotasks();
|
||||||
|
provider.dispose();
|
||||||
|
async.flushMicrotasks();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stale service callback cannot mutate or resync a replacement session', () async {
|
||||||
|
final factory = _FakePeerServiceFactory();
|
||||||
|
final provider = WatchTogetherProvider(peerServiceFactory: factory.call);
|
||||||
|
var notifications = 0;
|
||||||
|
provider.addListener(() => notifications++);
|
||||||
|
|
||||||
|
await provider.createSession(
|
||||||
|
controlMode: ControlMode.hostOnly,
|
||||||
|
relayEndpoint: WatchTogetherRelayEndpoint.defaultEndpoint,
|
||||||
|
sessionId: 'old1',
|
||||||
|
);
|
||||||
|
await _flushProviderEvents();
|
||||||
|
final oldService = factory.services.single;
|
||||||
|
final staleReconnect = oldService.onReconnected!;
|
||||||
|
oldService.emitError(_transportError('old transport error'));
|
||||||
|
await _flushProviderEvents();
|
||||||
|
|
||||||
|
await provider.createSession(
|
||||||
|
controlMode: ControlMode.anyone,
|
||||||
|
relayEndpoint: WatchTogetherRelayEndpoint.defaultEndpoint,
|
||||||
|
sessionId: 'new1',
|
||||||
|
);
|
||||||
|
await _flushProviderEvents();
|
||||||
|
final currentService = factory.services.last;
|
||||||
|
currentService.broadcasts.clear();
|
||||||
|
notifications = 0;
|
||||||
|
final replacement = provider.session;
|
||||||
|
|
||||||
|
staleReconnect();
|
||||||
|
await _flushProviderEvents();
|
||||||
|
expect(provider.session, replacement);
|
||||||
|
expect(notifications, 0);
|
||||||
|
expect(currentService.broadcasts, isEmpty);
|
||||||
|
|
||||||
|
currentService.emitError(_transportError('current transport error'));
|
||||||
|
await _flushProviderEvents();
|
||||||
|
currentService.reconnect();
|
||||||
|
await _flushProviderEvents();
|
||||||
|
expect(provider.session?.state, SessionState.connected);
|
||||||
|
expect(provider.session?.errorMessage, isNull);
|
||||||
|
expect(currentService.broadcasts.where((message) => message.type == SyncMessageType.join), hasLength(1));
|
||||||
|
|
||||||
|
await provider.leaveSession();
|
||||||
|
provider.dispose();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('WatchTogetherProvider — release cleanup', () {
|
||||||
|
test('release failure is surfaced after local session teardown completes', () async {
|
||||||
|
final releaseFailure = StateError('relay release failed');
|
||||||
|
final factory = _FakePeerServiceFactory(releaseError: releaseFailure);
|
||||||
|
final provider = WatchTogetherProvider(peerServiceFactory: factory.call);
|
||||||
|
addTearDown(provider.dispose);
|
||||||
|
|
||||||
|
await provider.createSession(
|
||||||
|
controlMode: ControlMode.hostOnly,
|
||||||
|
relayEndpoint: WatchTogetherRelayEndpoint.defaultEndpoint,
|
||||||
|
sessionId: 'fail1',
|
||||||
|
);
|
||||||
|
final service = factory.services.single;
|
||||||
|
|
||||||
|
await expectLater(provider.leaveSession(), throwsA(same(releaseFailure)));
|
||||||
|
|
||||||
|
expect(service.releaseCalls, 1);
|
||||||
|
expect(service.didDisconnect, isTrue);
|
||||||
|
expect(service.isDisposed, isTrue);
|
||||||
|
expect(provider.session, isNull);
|
||||||
|
expect(provider.isInSession, isFalse);
|
||||||
|
expect(provider.participants, isEmpty);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('WatchTogetherProvider — relay authority', () {
|
||||||
|
test('an empty successful probe joins the reserved room without creating', () async {
|
||||||
|
late final _ProviderRelay relay;
|
||||||
|
relay = await _ProviderRelay.start((socket, message) {
|
||||||
|
if (message['type'] == 'join') {
|
||||||
|
relay.send(socket, {
|
||||||
|
'type': 'joined',
|
||||||
|
'sessionId': message['sessionId'],
|
||||||
|
'hostPeerId': _providerHostId,
|
||||||
|
'reconnectToken': message['reconnectToken'],
|
||||||
|
'protocolVersion': 2,
|
||||||
|
});
|
||||||
|
} else if (message['type'] == 'leave') {
|
||||||
|
relay.send(socket, {
|
||||||
|
'type': 'left',
|
||||||
|
'sessionId': message['sessionId'],
|
||||||
|
'peerId': message['peerId'],
|
||||||
|
'protocolVersion': 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
addTearDown(relay.close);
|
||||||
|
final endpoint = WatchTogetherRelayEndpoint.resolve(relay.baseUrl);
|
||||||
|
final provider = WatchTogetherProvider();
|
||||||
|
addTearDown(() async {
|
||||||
|
await provider.leaveSession();
|
||||||
|
provider.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
final becameHost = await provider.enterRoom('empty1', relayEndpoint: endpoint, displayName: 'Guest');
|
||||||
|
|
||||||
|
expect(becameHost, isFalse);
|
||||||
|
expect(provider.isHost, isFalse);
|
||||||
|
expect(provider.session?.hostPeerId, _providerHostId);
|
||||||
|
final joins = relay.messages.where((message) => message['type'] == 'join').toList();
|
||||||
|
expect(joins, hasLength(2));
|
||||||
|
for (final join in joins) {
|
||||||
|
expect(join['protocolVersion'], 2);
|
||||||
|
expect(join['reconnectToken'], matches(RegExp(r'^[A-Za-z0-9_-]{43}$')));
|
||||||
|
}
|
||||||
|
final leave = relay.messages.singleWhere((message) => message['type'] == 'leave');
|
||||||
|
expect(leave['peerId'], joins.first['peerId']);
|
||||||
|
expect(leave['reconnectToken'], joins.first['reconnectToken']);
|
||||||
|
expect(leave['protocolVersion'], RelayProtocol.protocolVersion);
|
||||||
|
expect(joins.last['peerId'], isNot(joins.first['peerId']));
|
||||||
|
expect(relay.messages.map((message) => message['type']).take(3), ['join', 'leave', 'join']);
|
||||||
|
expect(relay.messages.where((message) => message['type'] == 'create'), isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a room-not-found probe creates with relay-declared host authority', () async {
|
||||||
|
late final _ProviderRelay relay;
|
||||||
|
relay = await _ProviderRelay.start((socket, message) {
|
||||||
|
if (message['type'] == 'join') {
|
||||||
|
relay.send(socket, {'type': 'error', 'code': 'room_not_found', 'message': 'Room not found'});
|
||||||
|
} else if (message['type'] == 'create') {
|
||||||
|
relay.send(socket, {
|
||||||
|
'type': 'created',
|
||||||
|
'sessionId': message['sessionId'],
|
||||||
|
'hostPeerId': message['peerId'],
|
||||||
|
'reconnectToken': message['reconnectToken'],
|
||||||
|
'protocolVersion': 2,
|
||||||
|
});
|
||||||
|
} else if (message['type'] == 'endSession') {
|
||||||
|
relay.send(socket, {'type': 'ended', 'sessionId': message['sessionId'], 'protocolVersion': 2});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
addTearDown(relay.close);
|
||||||
|
final endpoint = WatchTogetherRelayEndpoint.resolve(relay.baseUrl);
|
||||||
|
final provider = WatchTogetherProvider();
|
||||||
|
addTearDown(() async {
|
||||||
|
await provider.leaveSession();
|
||||||
|
provider.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
final becameHost = await provider.enterRoom('new01', relayEndpoint: endpoint, displayName: 'Host');
|
||||||
|
|
||||||
|
expect(becameHost, isTrue);
|
||||||
|
expect(provider.isHost, isTrue);
|
||||||
|
final create = relay.messages.singleWhere((message) => message['type'] == 'create');
|
||||||
|
expect(provider.session?.hostPeerId, create['peerId']);
|
||||||
|
expect(create['peerId'], isNot('wt-NEW01'));
|
||||||
|
expect(create['protocolVersion'], 2);
|
||||||
|
expect(create['reconnectToken'], matches(RegExp(r'^[A-Za-z0-9_-]{43}$')));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('WatchTogetherProvider — terminal room lifecycle', () {
|
||||||
|
test('relay ended notification exits immediately without release or reconnect grace', () async {
|
||||||
|
final factory = _FakePeerServiceFactory();
|
||||||
|
final provider = WatchTogetherProvider(peerServiceFactory: factory.call);
|
||||||
|
var hostExitCalls = 0;
|
||||||
|
provider.onHostExitedPlayer = () => hostExitCalls++;
|
||||||
|
|
||||||
|
await provider.joinSession(
|
||||||
|
'ended1',
|
||||||
|
relayEndpoint: WatchTogetherRelayEndpoint.defaultEndpoint,
|
||||||
|
displayName: 'Guest',
|
||||||
|
);
|
||||||
|
final service = factory.services.single;
|
||||||
|
service.emitPeerDisconnected(provider.session!.hostPeerId!);
|
||||||
|
await _flushProviderEvents();
|
||||||
|
expect(provider.isWaitingForHostReconnect, isTrue);
|
||||||
|
|
||||||
|
service.emitSessionEnded();
|
||||||
|
await _flushProviderEvents();
|
||||||
|
|
||||||
|
expect(hostExitCalls, 1);
|
||||||
|
expect(provider.session, isNull);
|
||||||
|
expect(provider.isWaitingForHostReconnect, isFalse);
|
||||||
|
expect(service.releaseCalls, 0);
|
||||||
|
expect(service.didDisconnect, isTrue);
|
||||||
|
expect(service.isDisposed, isTrue);
|
||||||
|
provider.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('best-effort host-leave cleanup observes release failures', () async {
|
||||||
|
final releaseFailure = StateError('relay release failed');
|
||||||
|
final factory = _FakePeerServiceFactory(releaseError: releaseFailure);
|
||||||
|
final uncaught = <Object>[];
|
||||||
|
|
||||||
|
await runZonedGuarded(() async {
|
||||||
|
final provider = WatchTogetherProvider(peerServiceFactory: factory.call);
|
||||||
|
await provider.joinSession(
|
||||||
|
'leave2',
|
||||||
|
relayEndpoint: WatchTogetherRelayEndpoint.defaultEndpoint,
|
||||||
|
displayName: 'Guest',
|
||||||
|
);
|
||||||
|
final service = factory.services.single;
|
||||||
|
service.emitMessage(SyncMessage.leave(peerId: provider.session!.hostPeerId!));
|
||||||
|
await _flushProviderEvents();
|
||||||
|
expect(provider.session, isNull);
|
||||||
|
expect(service.releaseCalls, 1);
|
||||||
|
provider.dispose();
|
||||||
|
}, (error, _) => uncaught.add(error));
|
||||||
|
|
||||||
|
expect(uncaught, isEmpty);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
group('WatchTogetherProvider — dispose hygiene', () {
|
group('WatchTogetherProvider — dispose hygiene', () {
|
||||||
test('participantEvents stream is closed after dispose', () async {
|
test('participantEvents stream is closed after dispose', () async {
|
||||||
final p = WatchTogetherProvider();
|
final p = WatchTogetherProvider();
|
||||||
@@ -223,5 +849,37 @@ void main() {
|
|||||||
await sub.cancel();
|
await sub.cancel();
|
||||||
expect(streamDone, isTrue);
|
expect(streamDone, isTrue);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('dispose detaches local listeners before relay release completes', () async {
|
||||||
|
final releaseCompleter = Completer<void>();
|
||||||
|
final factory = _FakePeerServiceFactory(releaseBarrier: releaseCompleter.future);
|
||||||
|
final provider = WatchTogetherProvider(peerServiceFactory: factory.call);
|
||||||
|
await provider.joinSession(
|
||||||
|
'dispose1',
|
||||||
|
relayEndpoint: WatchTogetherRelayEndpoint.defaultEndpoint,
|
||||||
|
displayName: 'Guest',
|
||||||
|
);
|
||||||
|
final service = factory.services.single;
|
||||||
|
expect(service.hasRelayListeners, isTrue);
|
||||||
|
|
||||||
|
provider.dispose();
|
||||||
|
|
||||||
|
expect(provider.session, isNull);
|
||||||
|
expect(provider.participants, isEmpty);
|
||||||
|
expect(provider.isWaitingForHostReconnect, isFalse);
|
||||||
|
expect(service.hasRelayListeners, isFalse);
|
||||||
|
expect(service.releaseCalls, 1);
|
||||||
|
expect(service.didDisconnect, isFalse);
|
||||||
|
|
||||||
|
service.emitPeerConnected('late-peer');
|
||||||
|
service.emitMessage(SyncMessage.join(peerId: 'late-peer', displayName: 'Late', isHost: false));
|
||||||
|
await _flushProviderEvents();
|
||||||
|
expect(provider.participants, isEmpty);
|
||||||
|
|
||||||
|
releaseCompleter.complete();
|
||||||
|
await _flushProviderEvents();
|
||||||
|
expect(service.didDisconnect, isTrue);
|
||||||
|
expect(service.isDisposed, isTrue);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user