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/test_check_workflow_action_pins.py
|
||||
python3 scripts/test_check_codegen.py
|
||||
python3 scripts/test_generate_relay_protocol.py
|
||||
python3 scripts/test_format_native.py
|
||||
python3 scripts/test_run_maestro.py
|
||||
python3 scripts/check_update_packages_workflow.py
|
||||
|
||||
@@ -180,6 +180,7 @@
|
||||
"watchTogetherRelay": "Релей сървър за гледане заедно",
|
||||
"watchTogetherRelayDescription": "Задай собствен релей сървър. Всички трябва да използват същия сървър.",
|
||||
"watchTogetherRelayHint": "https://my-relay.example.com",
|
||||
"watchTogetherRelayInvalid": "",
|
||||
"crashReporting": "Докладване на сривове",
|
||||
"crashReportingDescription": "Изпращай доклади за сривове, за да помогнеш за подобряване на приложението",
|
||||
"debugLogging": "Логове за отстраняване на грешки",
|
||||
|
||||
@@ -180,6 +180,7 @@
|
||||
"watchTogetherRelay": "Watch Together-relay",
|
||||
"watchTogetherRelayDescription": "Angiv en brugerdefineret relay. Alle skal bruge samme server.",
|
||||
"watchTogetherRelayHint": "https://min-relay.eksempel.dk",
|
||||
"watchTogetherRelayInvalid": "",
|
||||
"crashReporting": "Fejlrapportering",
|
||||
"crashReportingDescription": "Send fejlrapporter for at hjælpe med at forbedre appen",
|
||||
"debugLogging": "Fejlfindingslogning",
|
||||
|
||||
@@ -180,6 +180,7 @@
|
||||
"watchTogetherRelay": "Gemeinsam Schauen Relay",
|
||||
"watchTogetherRelayDescription": "Eigenes Relay festlegen. Alle müssen denselben Server verwenden.",
|
||||
"watchTogetherRelayHint": "https://mein-relay.beispiel.de",
|
||||
"watchTogetherRelayInvalid": "",
|
||||
"crashReporting": "Absturzberichte",
|
||||
"crashReportingDescription": "Absturzberichte senden, um die App zu verbessern",
|
||||
"debugLogging": "Debug-Protokollierung",
|
||||
|
||||
@@ -180,6 +180,7 @@
|
||||
"watchTogetherRelay": "Watch Together Relay",
|
||||
"watchTogetherRelayDescription": "Set a custom relay. Everyone must use the same server.",
|
||||
"watchTogetherRelayHint": "https://my-relay.example.com",
|
||||
"watchTogetherRelayInvalid": "Enter a valid HTTP or HTTPS relay base URL.",
|
||||
"crashReporting": "Crash Reporting",
|
||||
"crashReportingDescription": "Send crash reports to help improve the app",
|
||||
"debugLogging": "Debug Logging",
|
||||
|
||||
@@ -180,6 +180,7 @@
|
||||
"watchTogetherRelay": "Relay de Ver Juntos",
|
||||
"watchTogetherRelayDescription": "Define un relay personalizado. Todos deben usar el mismo servidor.",
|
||||
"watchTogetherRelayHint": "https://mi-relay.ejemplo.com",
|
||||
"watchTogetherRelayInvalid": "",
|
||||
"crashReporting": "Informes de Errores",
|
||||
"crashReportingDescription": "Enviar informes de errores para mejorar la aplicación",
|
||||
"debugLogging": "Registro de Depuración",
|
||||
|
||||
@@ -180,6 +180,7 @@
|
||||
"watchTogetherRelay": "Relais Regarder Ensemble",
|
||||
"watchTogetherRelayDescription": "Définir un relay personnalisé. Tout le monde doit utiliser le même serveur.",
|
||||
"watchTogetherRelayHint": "https://mon-relais.exemple.fr",
|
||||
"watchTogetherRelayInvalid": "",
|
||||
"crashReporting": "Rapports de plantage",
|
||||
"crashReportingDescription": "Envoyer des rapports de plantage pour améliorer l'application",
|
||||
"debugLogging": "Journalisation de débogage",
|
||||
|
||||
@@ -180,6 +180,7 @@
|
||||
"watchTogetherRelay": "Relay Guarda Insieme",
|
||||
"watchTogetherRelayDescription": "Imposta un relay personalizzato. Tutti devono usare lo stesso server.",
|
||||
"watchTogetherRelayHint": "https://mio-relay.esempio.it",
|
||||
"watchTogetherRelayInvalid": "",
|
||||
"crashReporting": "Segnalazione errori",
|
||||
"crashReportingDescription": "Invia segnalazioni di errori per migliorare l'app",
|
||||
"debugLogging": "Log di debug",
|
||||
|
||||
@@ -180,6 +180,7 @@
|
||||
"watchTogetherRelay": "一緒に視聴リレーサーバー",
|
||||
"watchTogetherRelayDescription": "カスタムリレーを設定します。全員が同じサーバーを使う必要があります。",
|
||||
"watchTogetherRelayHint": "https://my-relay.example.com",
|
||||
"watchTogetherRelayInvalid": "",
|
||||
"crashReporting": "クラッシュレポート",
|
||||
"crashReportingDescription": "アプリの改善に役立つクラッシュレポートを送信",
|
||||
"debugLogging": "デバッグログ",
|
||||
|
||||
@@ -180,6 +180,7 @@
|
||||
"watchTogetherRelay": "함께 보기 릴레이",
|
||||
"watchTogetherRelayDescription": "사용자 지정 릴레이를 설정합니다. 모두 같은 서버를 사용해야 합니다.",
|
||||
"watchTogetherRelayHint": "https://my-relay.example.com",
|
||||
"watchTogetherRelayInvalid": "",
|
||||
"crashReporting": "충돌 보고",
|
||||
"crashReportingDescription": "앱 개선을 위해 충돌 보고서 전송",
|
||||
"debugLogging": "디버그 로깅",
|
||||
|
||||
@@ -180,6 +180,7 @@
|
||||
"watchTogetherRelay": "Se Sammen-relay",
|
||||
"watchTogetherRelayDescription": "Angi en egendefinert relay. Alle må bruke samme server.",
|
||||
"watchTogetherRelayHint": "https://min-relay.eksempel.no",
|
||||
"watchTogetherRelayInvalid": "",
|
||||
"crashReporting": "Krasjrapportering",
|
||||
"crashReportingDescription": "Send krasjrapporter for å hjelpe med å forbedre appen",
|
||||
"debugLogging": "Feilsøkingslogging",
|
||||
|
||||
@@ -180,6 +180,7 @@
|
||||
"watchTogetherRelay": "Samen Kijken Relay",
|
||||
"watchTogetherRelayDescription": "Stel een aangepaste relay in. Iedereen moet dezelfde server gebruiken.",
|
||||
"watchTogetherRelayHint": "https://mijn-relay.voorbeeld.nl",
|
||||
"watchTogetherRelayInvalid": "",
|
||||
"crashReporting": "Crashrapportage",
|
||||
"crashReportingDescription": "Crashrapporten verzenden om de app te verbeteren",
|
||||
"debugLogging": "Debug logging",
|
||||
|
||||
@@ -180,6 +180,7 @@
|
||||
"watchTogetherRelay": "Relay Oglądaj Razem",
|
||||
"watchTogetherRelayDescription": "Ustaw własny relay. Wszyscy muszą używać tego samego serwera.",
|
||||
"watchTogetherRelayHint": "https://moj-relay.przyklad.pl",
|
||||
"watchTogetherRelayInvalid": "",
|
||||
"crashReporting": "Raportowanie błędów",
|
||||
"crashReportingDescription": "Wysyłaj raporty o błędach, aby pomóc ulepszyć aplikację",
|
||||
"debugLogging": "Logowanie debugowania",
|
||||
|
||||
@@ -180,6 +180,7 @@
|
||||
"watchTogetherRelay": "Relay do Assistir Juntos",
|
||||
"watchTogetherRelayDescription": "Defina um relay personalizado. Todos devem usar o mesmo servidor.",
|
||||
"watchTogetherRelayHint": "https://meu-relay.exemplo.com.br",
|
||||
"watchTogetherRelayInvalid": "",
|
||||
"crashReporting": "Relatório de Erros",
|
||||
"crashReportingDescription": "Enviar relatórios de erros para ajudar a melhorar o app",
|
||||
"debugLogging": "Log de Depuração",
|
||||
|
||||
@@ -180,6 +180,7 @@
|
||||
"watchTogetherRelay": "Relay совместного просмотра",
|
||||
"watchTogetherRelayDescription": "Задайте свой relay. Все должны использовать один сервер.",
|
||||
"watchTogetherRelayHint": "https://my-relay.example.com",
|
||||
"watchTogetherRelayInvalid": "",
|
||||
"crashReporting": "Отчёты об ошибках",
|
||||
"crashReportingDescription": "Отправлять отчёты об ошибках для улучшения приложения",
|
||||
"debugLogging": "Журнал отладки",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
/// To regenerate, run: `dart run slang`
|
||||
///
|
||||
/// Locales: 16
|
||||
/// Strings: 23011 (1438 per locale)
|
||||
/// Strings: 23027 (1439 per locale)
|
||||
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: type=lint, unused_import
|
||||
|
||||
@@ -311,6 +311,7 @@ class _TranslationsSettingsBg extends TranslationsSettingsEn {
|
||||
@override String get watchTogetherRelay => 'Релей сървър за гледане заедно';
|
||||
@override String get watchTogetherRelayDescription => 'Задай собствен релей сървър. Всички трябва да използват същия сървър.';
|
||||
@override String get watchTogetherRelayHint => 'https://my-relay.example.com';
|
||||
@override String get watchTogetherRelayInvalid => '';
|
||||
@override String get crashReporting => 'Докладване на сривове';
|
||||
@override String get crashReportingDescription => 'Изпращай доклади за сривове, за да помогнеш за подобряване на приложението';
|
||||
@override String get debugLogging => 'Логове за отстраняване на грешки';
|
||||
@@ -2303,6 +2304,7 @@ extension on TranslationsBg {
|
||||
'settings.watchTogetherRelay' => 'Релей сървър за гледане заедно',
|
||||
'settings.watchTogetherRelayDescription' => 'Задай собствен релей сървър. Всички трябва да използват същия сървър.',
|
||||
'settings.watchTogetherRelayHint' => 'https://my-relay.example.com',
|
||||
'settings.watchTogetherRelayInvalid' => '',
|
||||
'settings.crashReporting' => 'Докладване на сривове',
|
||||
'settings.crashReportingDescription' => 'Изпращай доклади за сривове, за да помогнеш за подобряване на приложението',
|
||||
'settings.debugLogging' => 'Логове за отстраняване на грешки',
|
||||
@@ -2644,9 +2646,9 @@ extension on TranslationsBg {
|
||||
'messages.markedAsUnwatchedOffline' => 'Маркирано като негледано (ще се синхронизира, когато сте онлайн)',
|
||||
'messages.autoRemovedWatchedDownload' => ({required Object title}) => 'Автоматично премахнато: ${title}',
|
||||
'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('bg'))(n, one: 'Автоматично премахнато ${n} гледано изтегляне', other: 'Автоматично премахнати ${n} гледани изтегляния', ),
|
||||
'messages.removedFromContinueWatching' => 'Премахнато от продължаване на гледането',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'messages.removedFromContinueWatching' => 'Премахнато от продължаване на гледането',
|
||||
'messages.errorLoading' => ({required Object error}) => 'Грешка: ${error}',
|
||||
'messages.streamInterrupted' => 'Потокът прекъсна. Натиснете „Пусни“ или превъртете, за да опитате отново.',
|
||||
'messages.liveStreamInterrupted' => 'Потокът на живо прекъсна. Натиснете „Пусни“, за да опитате отново.',
|
||||
@@ -3158,9 +3160,9 @@ extension on TranslationsBg {
|
||||
'watchTogether.participantSeeked' => ({required Object name}) => '${name} превъртя',
|
||||
'watchTogether.participantBuffering' => ({required Object name}) => '${name} буферира',
|
||||
'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} е с по-стара версия на приложението — синхронизирането не е налично',
|
||||
'watchTogether.resumingWithout' => ({required Object name}) => 'Продължаване без ${name}',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.resumingWithout' => ({required Object name}) => 'Продължаване без ${name}',
|
||||
'watchTogether.waitingForParticipants' => 'Изчакване другите да заредят...',
|
||||
'watchTogether.waitingForName' => ({required Object name}) => 'Изчакване на ${name}...',
|
||||
'watchTogether.recentRooms' => 'Скорошни стаи',
|
||||
|
||||
@@ -311,6 +311,7 @@ class _TranslationsSettingsDa extends TranslationsSettingsEn {
|
||||
@override String get watchTogetherRelay => 'Watch Together-relay';
|
||||
@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 watchTogetherRelayInvalid => '';
|
||||
@override String get crashReporting => 'Fejlrapportering';
|
||||
@override String get crashReportingDescription => 'Send fejlrapporter for at hjælpe med at forbedre appen';
|
||||
@override String get debugLogging => 'Fejlfindingslogning';
|
||||
@@ -2303,6 +2304,7 @@ extension on TranslationsDa {
|
||||
'settings.watchTogetherRelay' => 'Watch Together-relay',
|
||||
'settings.watchTogetherRelayDescription' => 'Angiv en brugerdefineret relay. Alle skal bruge samme server.',
|
||||
'settings.watchTogetherRelayHint' => 'https://min-relay.eksempel.dk',
|
||||
'settings.watchTogetherRelayInvalid' => '',
|
||||
'settings.crashReporting' => 'Fejlrapportering',
|
||||
'settings.crashReportingDescription' => 'Send fejlrapporter for at hjælpe med at forbedre appen',
|
||||
'settings.debugLogging' => 'Fejlfindingslogning',
|
||||
@@ -2644,9 +2646,9 @@ extension on TranslationsDa {
|
||||
'messages.markedAsUnwatchedOffline' => 'Markeret som uset (synkroniseres online)',
|
||||
'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.removedFromContinueWatching' => 'Fjernet fra Fortsæt med at se',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'messages.removedFromContinueWatching' => 'Fjernet fra Fortsæt med at se',
|
||||
'messages.errorLoading' => ({required Object error}) => 'Fejl: ${error}',
|
||||
'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.',
|
||||
@@ -3158,9 +3160,9 @@ extension on TranslationsDa {
|
||||
'watchTogether.participantSeeked' => ({required Object name}) => '${name} spoled',
|
||||
'watchTogether.participantBuffering' => ({required Object name}) => '${name} bufferer',
|
||||
'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,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.resumingWithout' => ({required Object name}) => 'Fortsætter uden ${name}',
|
||||
'watchTogether.waitingForParticipants' => 'Venter på at andre indlæser...',
|
||||
'watchTogether.waitingForName' => ({required Object name}) => 'Venter på ${name}...',
|
||||
'watchTogether.recentRooms' => 'Seneste rum',
|
||||
|
||||
@@ -311,6 +311,7 @@ class _TranslationsSettingsDe extends TranslationsSettingsEn {
|
||||
@override String get watchTogetherRelay => 'Gemeinsam Schauen Relay';
|
||||
@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 watchTogetherRelayInvalid => '';
|
||||
@override String get crashReporting => 'Absturzberichte';
|
||||
@override String get crashReportingDescription => 'Absturzberichte senden, um die App zu verbessern';
|
||||
@override String get debugLogging => 'Debug-Protokollierung';
|
||||
@@ -2303,6 +2304,7 @@ extension on TranslationsDe {
|
||||
'settings.watchTogetherRelay' => 'Gemeinsam Schauen Relay',
|
||||
'settings.watchTogetherRelayDescription' => 'Eigenes Relay festlegen. Alle müssen denselben Server verwenden.',
|
||||
'settings.watchTogetherRelayHint' => 'https://mein-relay.beispiel.de',
|
||||
'settings.watchTogetherRelayInvalid' => '',
|
||||
'settings.crashReporting' => 'Absturzberichte',
|
||||
'settings.crashReportingDescription' => 'Absturzberichte senden, um die App zu verbessern',
|
||||
'settings.debugLogging' => 'Debug-Protokollierung',
|
||||
@@ -2644,9 +2646,9 @@ extension on TranslationsDe {
|
||||
'messages.markedAsUnwatchedOffline' => 'Als ungesehen markiert (wird synchronisiert, wenn online)',
|
||||
'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.removedFromContinueWatching' => 'Aus ‚Weiterschauen\' entfernt',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'messages.removedFromContinueWatching' => 'Aus ‚Weiterschauen\' entfernt',
|
||||
'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.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.participantBuffering' => ({required Object name}) => '${name} puffert',
|
||||
'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} verwendet eine ältere Appversion — Synchronisierung nicht verfügbar',
|
||||
'watchTogether.resumingWithout' => ({required Object name}) => 'Fortfahren ohne ${name}',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.resumingWithout' => ({required Object name}) => 'Fortfahren ohne ${name}',
|
||||
'watchTogether.waitingForParticipants' => 'Warte auf andere zum Laden...',
|
||||
'watchTogether.waitingForName' => ({required Object name}) => 'Warten auf ${name}...',
|
||||
'watchTogether.recentRooms' => 'Letzte Räume',
|
||||
|
||||
@@ -653,6 +653,9 @@ class TranslationsSettingsEn {
|
||||
/// en: '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'
|
||||
String get crashReporting => 'Crash Reporting';
|
||||
|
||||
@@ -5180,6 +5183,7 @@ extension on Translations {
|
||||
'settings.watchTogetherRelay' => 'Watch Together Relay',
|
||||
'settings.watchTogetherRelayDescription' => 'Set a custom relay. Everyone must use the same server.',
|
||||
'settings.watchTogetherRelayHint' => 'https://my-relay.example.com',
|
||||
'settings.watchTogetherRelayInvalid' => 'Enter a valid HTTP or HTTPS relay base URL.',
|
||||
'settings.crashReporting' => 'Crash Reporting',
|
||||
'settings.crashReportingDescription' => 'Send crash reports to help improve the app',
|
||||
'settings.debugLogging' => 'Debug Logging',
|
||||
@@ -5522,9 +5526,9 @@ extension on Translations {
|
||||
'messages.markedAsUnwatchedOffline' => 'Marked as unwatched (will sync when online)',
|
||||
'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.removedFromContinueWatching' => 'Removed from Continue Watching',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'messages.removedFromContinueWatching' => 'Removed from Continue Watching',
|
||||
'messages.errorLoading' => ({required Object error}) => 'Error: ${error}',
|
||||
'messages.streamInterrupted' => 'The stream was interrupted. Press play or seek 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.participantBuffering' => ({required Object name}) => '${name} is buffering',
|
||||
'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} is on an older app version — sync unavailable',
|
||||
'watchTogether.resumingWithout' => ({required Object name}) => 'Resuming without ${name}',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.resumingWithout' => ({required Object name}) => 'Resuming without ${name}',
|
||||
'watchTogether.waitingForParticipants' => 'Waiting for others to load...',
|
||||
'watchTogether.waitingForName' => ({required Object name}) => 'Waiting for ${name}...',
|
||||
'watchTogether.recentRooms' => 'Recent Rooms',
|
||||
|
||||
@@ -311,6 +311,7 @@ class _TranslationsSettingsEs extends TranslationsSettingsEn {
|
||||
@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 watchTogetherRelayHint => 'https://mi-relay.ejemplo.com';
|
||||
@override String get watchTogetherRelayInvalid => '';
|
||||
@override String get crashReporting => 'Informes de Errores';
|
||||
@override String get crashReportingDescription => 'Enviar informes de errores para mejorar la aplicación';
|
||||
@override String get debugLogging => 'Registro de Depuración';
|
||||
@@ -2303,6 +2304,7 @@ extension on TranslationsEs {
|
||||
'settings.watchTogetherRelay' => 'Relay de Ver Juntos',
|
||||
'settings.watchTogetherRelayDescription' => 'Define un relay personalizado. Todos deben usar el mismo servidor.',
|
||||
'settings.watchTogetherRelayHint' => 'https://mi-relay.ejemplo.com',
|
||||
'settings.watchTogetherRelayInvalid' => '',
|
||||
'settings.crashReporting' => 'Informes de Errores',
|
||||
'settings.crashReportingDescription' => 'Enviar informes de errores para mejorar la aplicació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.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.removedFromContinueWatching' => 'Eliminado de Seguir Viendo',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'messages.removedFromContinueWatching' => 'Eliminado de Seguir Viendo',
|
||||
'messages.errorLoading' => ({required Object error}) => 'Error: ${error}',
|
||||
'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.',
|
||||
@@ -3158,9 +3160,9 @@ extension on TranslationsEs {
|
||||
'watchTogether.participantSeeked' => ({required Object name}) => '${name} avanzó',
|
||||
'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.resumingWithout' => ({required Object name}) => 'Reanudando sin ${name}',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.resumingWithout' => ({required Object name}) => 'Reanudando sin ${name}',
|
||||
'watchTogether.waitingForParticipants' => 'Esperando a que otros carguen...',
|
||||
'watchTogether.waitingForName' => ({required Object name}) => 'Esperando a ${name}...',
|
||||
'watchTogether.recentRooms' => 'Salas recientes',
|
||||
|
||||
@@ -311,6 +311,7 @@ class _TranslationsSettingsFr extends TranslationsSettingsEn {
|
||||
@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 watchTogetherRelayHint => 'https://mon-relais.exemple.fr';
|
||||
@override String get watchTogetherRelayInvalid => '';
|
||||
@override String get crashReporting => 'Rapports de plantage';
|
||||
@override String get crashReportingDescription => 'Envoyer des rapports de plantage pour améliorer l\'application';
|
||||
@override String get debugLogging => 'Journalisation de débogage';
|
||||
@@ -2303,6 +2304,7 @@ extension on TranslationsFr {
|
||||
'settings.watchTogetherRelay' => 'Relais Regarder Ensemble',
|
||||
'settings.watchTogetherRelayDescription' => 'Définir un relay personnalisé. Tout le monde doit utiliser le même serveur.',
|
||||
'settings.watchTogetherRelayHint' => 'https://mon-relais.exemple.fr',
|
||||
'settings.watchTogetherRelayInvalid' => '',
|
||||
'settings.crashReporting' => 'Rapports de plantage',
|
||||
'settings.crashReportingDescription' => 'Envoyer des rapports de plantage pour améliorer l\'application',
|
||||
'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.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.removedFromContinueWatching' => 'Supprimer de "Continuer à regarder"',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'messages.removedFromContinueWatching' => 'Supprimer de "Continuer à regarder"',
|
||||
'messages.errorLoading' => ({required Object error}) => 'Erreur: ${error}',
|
||||
'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.',
|
||||
@@ -3158,9 +3160,9 @@ extension on TranslationsFr {
|
||||
'watchTogether.participantSeeked' => ({required Object name}) => '${name} a avancé',
|
||||
'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.resumingWithout' => ({required Object name}) => 'Reprise sans ${name}',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.resumingWithout' => ({required Object name}) => 'Reprise sans ${name}',
|
||||
'watchTogether.waitingForParticipants' => 'En attente du chargement des autres...',
|
||||
'watchTogether.waitingForName' => ({required Object name}) => 'En attente de ${name}...',
|
||||
'watchTogether.recentRooms' => 'Salons récents',
|
||||
|
||||
@@ -311,6 +311,7 @@ class _TranslationsSettingsIt extends TranslationsSettingsEn {
|
||||
@override String get watchTogetherRelay => 'Relay Guarda Insieme';
|
||||
@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 watchTogetherRelayInvalid => '';
|
||||
@override String get crashReporting => 'Segnalazione errori';
|
||||
@override String get crashReportingDescription => 'Invia segnalazioni di errori per migliorare l\'app';
|
||||
@override String get debugLogging => 'Log di debug';
|
||||
@@ -2303,6 +2304,7 @@ extension on TranslationsIt {
|
||||
'settings.watchTogetherRelay' => 'Relay Guarda Insieme',
|
||||
'settings.watchTogetherRelayDescription' => 'Imposta un relay personalizzato. Tutti devono usare lo stesso server.',
|
||||
'settings.watchTogetherRelayHint' => 'https://mio-relay.esempio.it',
|
||||
'settings.watchTogetherRelayInvalid' => '',
|
||||
'settings.crashReporting' => 'Segnalazione errori',
|
||||
'settings.crashReportingDescription' => 'Invia segnalazioni di errori per migliorare l\'app',
|
||||
'settings.debugLogging' => 'Log di debug',
|
||||
@@ -2644,9 +2646,9 @@ extension on TranslationsIt {
|
||||
'messages.markedAsUnwatchedOffline' => 'Segnato come non visto (sincronizzato online)',
|
||||
'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.removedFromContinueWatching' => 'Rimosso da Continua a guardare',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'messages.removedFromContinueWatching' => 'Rimosso da Continua a guardare',
|
||||
'messages.errorLoading' => ({required Object error}) => 'Errore: ${error}',
|
||||
'messages.streamInterrupted' => 'La riproduzione si è interrotta. Premi Riproduci o scorri 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.participantBuffering' => ({required Object name}) => '${name} sta caricando',
|
||||
'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} usa una versione precedente dell\'app — sincronizzazione non disponibile',
|
||||
'watchTogether.resumingWithout' => ({required Object name}) => 'Ripresa senza ${name}',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.resumingWithout' => ({required Object name}) => 'Ripresa senza ${name}',
|
||||
'watchTogether.waitingForParticipants' => 'In attesa che gli altri carichino...',
|
||||
'watchTogether.waitingForName' => ({required Object name}) => 'In attesa di ${name}...',
|
||||
'watchTogether.recentRooms' => 'Stanze recenti',
|
||||
|
||||
@@ -311,6 +311,7 @@ class _TranslationsSettingsJa extends TranslationsSettingsEn {
|
||||
@override String get watchTogetherRelay => '一緒に視聴リレーサーバー';
|
||||
@override String get watchTogetherRelayDescription => 'カスタムリレーを設定します。全員が同じサーバーを使う必要があります。';
|
||||
@override String get watchTogetherRelayHint => 'https://my-relay.example.com';
|
||||
@override String get watchTogetherRelayInvalid => '';
|
||||
@override String get crashReporting => 'クラッシュレポート';
|
||||
@override String get crashReportingDescription => 'アプリの改善に役立つクラッシュレポートを送信';
|
||||
@override String get debugLogging => 'デバッグログ';
|
||||
@@ -2300,6 +2301,7 @@ extension on TranslationsJa {
|
||||
'settings.watchTogetherRelay' => '一緒に視聴リレーサーバー',
|
||||
'settings.watchTogetherRelayDescription' => 'カスタムリレーを設定します。全員が同じサーバーを使う必要があります。',
|
||||
'settings.watchTogetherRelayHint' => 'https://my-relay.example.com',
|
||||
'settings.watchTogetherRelayInvalid' => '',
|
||||
'settings.crashReporting' => 'クラッシュレポート',
|
||||
'settings.crashReportingDescription' => 'アプリの改善に役立つクラッシュレポートを送信',
|
||||
'settings.debugLogging' => 'デバッグログ',
|
||||
@@ -2641,9 +2643,9 @@ extension on TranslationsJa {
|
||||
'messages.markedAsUnwatchedOffline' => '未視聴にしました(オンライン時に同期)',
|
||||
'messages.autoRemovedWatchedDownload' => ({required Object title}) => '自動削除: ${title}',
|
||||
'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('ja'))(n, other: '視聴済みダウンロードを${n}件自動削除しました', ),
|
||||
'messages.removedFromContinueWatching' => '視聴中から削除しました',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'messages.removedFromContinueWatching' => '視聴中から削除しました',
|
||||
'messages.errorLoading' => ({required Object error}) => 'エラー: ${error}',
|
||||
'messages.streamInterrupted' => 'ストリームが中断されました。再生を押すかシークして再試行してください。',
|
||||
'messages.liveStreamInterrupted' => 'ライブストリームが中断されました。再生を押して再試行してください。',
|
||||
@@ -3155,9 +3157,9 @@ extension on TranslationsJa {
|
||||
'watchTogether.participantSeeked' => ({required Object name}) => '${name}がシークしました',
|
||||
'watchTogether.participantBuffering' => ({required Object name}) => '${name}がバッファリング中',
|
||||
'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} は古いバージョンのアプリを使用しているため、同期できません',
|
||||
'watchTogether.resumingWithout' => ({required Object name}) => '${name} なしで再開',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.resumingWithout' => ({required Object name}) => '${name} なしで再開',
|
||||
'watchTogether.waitingForParticipants' => '他の参加者の読み込みを待っています...',
|
||||
'watchTogether.waitingForName' => ({required Object name}) => '${name}を待っています...',
|
||||
'watchTogether.recentRooms' => '最近のルーム',
|
||||
|
||||
@@ -311,6 +311,7 @@ class _TranslationsSettingsKo extends TranslationsSettingsEn {
|
||||
@override String get watchTogetherRelay => '함께 보기 릴레이';
|
||||
@override String get watchTogetherRelayDescription => '사용자 지정 릴레이를 설정합니다. 모두 같은 서버를 사용해야 합니다.';
|
||||
@override String get watchTogetherRelayHint => 'https://my-relay.example.com';
|
||||
@override String get watchTogetherRelayInvalid => '';
|
||||
@override String get crashReporting => '충돌 보고';
|
||||
@override String get crashReportingDescription => '앱 개선을 위해 충돌 보고서 전송';
|
||||
@override String get debugLogging => '디버그 로깅';
|
||||
@@ -2300,6 +2301,7 @@ extension on TranslationsKo {
|
||||
'settings.watchTogetherRelay' => '함께 보기 릴레이',
|
||||
'settings.watchTogetherRelayDescription' => '사용자 지정 릴레이를 설정합니다. 모두 같은 서버를 사용해야 합니다.',
|
||||
'settings.watchTogetherRelayHint' => 'https://my-relay.example.com',
|
||||
'settings.watchTogetherRelayInvalid' => '',
|
||||
'settings.crashReporting' => '충돌 보고',
|
||||
'settings.crashReportingDescription' => '앱 개선을 위해 충돌 보고서 전송',
|
||||
'settings.debugLogging' => '디버그 로깅',
|
||||
@@ -2641,9 +2643,9 @@ extension on TranslationsKo {
|
||||
'messages.markedAsUnwatchedOffline' => '미시청으로 표시됨 (연결 시 동기화됨)',
|
||||
'messages.autoRemovedWatchedDownload' => ({required Object title}) => '자동 삭제됨: ${title}',
|
||||
'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('ko'))(n, other: '시청한 다운로드 ${n}개를 자동 삭제했습니다', ),
|
||||
'messages.removedFromContinueWatching' => '계속 시청 목록에서 제거됨',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'messages.removedFromContinueWatching' => '계속 시청 목록에서 제거됨',
|
||||
'messages.errorLoading' => ({required Object error}) => '오류: ${error}',
|
||||
'messages.streamInterrupted' => '스트림이 중단되었습니다. 재생을 누르거나 탐색하여 다시 시도하세요.',
|
||||
'messages.liveStreamInterrupted' => '라이브 스트림이 중단되었습니다. 재생을 눌러 다시 시도하세요.',
|
||||
@@ -3155,9 +3157,9 @@ extension on TranslationsKo {
|
||||
'watchTogether.participantSeeked' => ({required Object name}) => '${name}님이 탐색했습니다',
|
||||
'watchTogether.participantBuffering' => ({required Object name}) => '${name}님이 버퍼링 중입니다',
|
||||
'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name}님이 이전 버전의 앱을 사용 중입니다 — 동기화를 사용할 수 없습니다',
|
||||
'watchTogether.resumingWithout' => ({required Object name}) => '${name}님 없이 재생을 재개합니다',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.resumingWithout' => ({required Object name}) => '${name}님 없이 재생을 재개합니다',
|
||||
'watchTogether.waitingForParticipants' => '다른 참가자의 로딩을 기다리는 중...',
|
||||
'watchTogether.waitingForName' => ({required Object name}) => '${name}님을 기다리는 중...',
|
||||
'watchTogether.recentRooms' => '최근 방',
|
||||
|
||||
@@ -311,6 +311,7 @@ class _TranslationsSettingsNb extends TranslationsSettingsEn {
|
||||
@override String get watchTogetherRelay => 'Se Sammen-relay';
|
||||
@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 watchTogetherRelayInvalid => '';
|
||||
@override String get crashReporting => 'Krasjrapportering';
|
||||
@override String get crashReportingDescription => 'Send krasjrapporter for å hjelpe med å forbedre appen';
|
||||
@override String get debugLogging => 'Feilsøkingslogging';
|
||||
@@ -2303,6 +2304,7 @@ extension on TranslationsNb {
|
||||
'settings.watchTogetherRelay' => 'Se Sammen-relay',
|
||||
'settings.watchTogetherRelayDescription' => 'Angi en egendefinert relay. Alle må bruke samme server.',
|
||||
'settings.watchTogetherRelayHint' => 'https://min-relay.eksempel.no',
|
||||
'settings.watchTogetherRelayInvalid' => '',
|
||||
'settings.crashReporting' => 'Krasjrapportering',
|
||||
'settings.crashReportingDescription' => 'Send krasjrapporter for å hjelpe med å forbedre appen',
|
||||
'settings.debugLogging' => 'Feilsøkingslogging',
|
||||
@@ -2644,9 +2646,9 @@ extension on TranslationsNb {
|
||||
'messages.markedAsUnwatchedOffline' => 'Merket som usett (synkroniseres når tilkoblet)',
|
||||
'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.removedFromContinueWatching' => 'Fjernet fra Fortsett å se',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'messages.removedFromContinueWatching' => 'Fjernet fra Fortsett å se',
|
||||
'messages.errorLoading' => ({required Object error}) => 'Feil: ${error}',
|
||||
'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.',
|
||||
@@ -3158,9 +3160,9 @@ extension on TranslationsNb {
|
||||
'watchTogether.participantSeeked' => ({required Object name}) => '${name} spolet',
|
||||
'watchTogether.participantBuffering' => ({required Object name}) => '${name} buffrer',
|
||||
'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} bruker en eldre appversjon — synkronisering er ikke tilgjengelig',
|
||||
'watchTogether.resumingWithout' => ({required Object name}) => 'Fortsetter uten ${name}',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.resumingWithout' => ({required Object name}) => 'Fortsetter uten ${name}',
|
||||
'watchTogether.waitingForParticipants' => 'Venter på at andre laster inn...',
|
||||
'watchTogether.waitingForName' => ({required Object name}) => 'Venter på ${name}...',
|
||||
'watchTogether.recentRooms' => 'Nylige rom',
|
||||
|
||||
@@ -311,6 +311,7 @@ class _TranslationsSettingsNl extends TranslationsSettingsEn {
|
||||
@override String get watchTogetherRelay => 'Samen Kijken Relay';
|
||||
@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 watchTogetherRelayInvalid => '';
|
||||
@override String get crashReporting => 'Crashrapportage';
|
||||
@override String get crashReportingDescription => 'Crashrapporten verzenden om de app te verbeteren';
|
||||
@override String get debugLogging => 'Debug logging';
|
||||
@@ -2303,6 +2304,7 @@ extension on TranslationsNl {
|
||||
'settings.watchTogetherRelay' => 'Samen Kijken Relay',
|
||||
'settings.watchTogetherRelayDescription' => 'Stel een aangepaste relay in. Iedereen moet dezelfde server gebruiken.',
|
||||
'settings.watchTogetherRelayHint' => 'https://mijn-relay.voorbeeld.nl',
|
||||
'settings.watchTogetherRelayInvalid' => '',
|
||||
'settings.crashReporting' => 'Crashrapportage',
|
||||
'settings.crashReportingDescription' => 'Crashrapporten verzenden om de app te verbeteren',
|
||||
'settings.debugLogging' => 'Debug logging',
|
||||
@@ -2644,9 +2646,9 @@ extension on TranslationsNl {
|
||||
'messages.markedAsUnwatchedOffline' => 'Gemarkeerd als ongekeken (sync wanneer online)',
|
||||
'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.removedFromContinueWatching' => 'Verwijderd uit Doorgaan met kijken',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'messages.removedFromContinueWatching' => 'Verwijderd uit Doorgaan met kijken',
|
||||
'messages.errorLoading' => ({required Object error}) => 'Fout: ${error}',
|
||||
'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.',
|
||||
@@ -3158,9 +3160,9 @@ extension on TranslationsNl {
|
||||
'watchTogether.participantSeeked' => ({required Object name}) => '${name} heeft gespoeld',
|
||||
'watchTogether.participantBuffering' => ({required Object name}) => '${name} is aan het bufferen',
|
||||
'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} gebruikt een oudere appversie — synchronisatie niet beschikbaar',
|
||||
'watchTogether.resumingWithout' => ({required Object name}) => 'Hervatten zonder ${name}',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.resumingWithout' => ({required Object name}) => 'Hervatten zonder ${name}',
|
||||
'watchTogether.waitingForParticipants' => 'Wachten tot anderen geladen zijn...',
|
||||
'watchTogether.waitingForName' => ({required Object name}) => 'Wachten op ${name}...',
|
||||
'watchTogether.recentRooms' => 'Recente kamers',
|
||||
|
||||
@@ -311,6 +311,7 @@ class _TranslationsSettingsPl extends TranslationsSettingsEn {
|
||||
@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 watchTogetherRelayHint => 'https://moj-relay.przyklad.pl';
|
||||
@override String get watchTogetherRelayInvalid => '';
|
||||
@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 debugLogging => 'Logowanie debugowania';
|
||||
@@ -2309,6 +2310,7 @@ extension on TranslationsPl {
|
||||
'settings.watchTogetherRelay' => 'Relay Oglądaj Razem',
|
||||
'settings.watchTogetherRelayDescription' => 'Ustaw własny relay. Wszyscy muszą używać tego samego serwera.',
|
||||
'settings.watchTogetherRelayHint' => 'https://moj-relay.przyklad.pl',
|
||||
'settings.watchTogetherRelayInvalid' => '',
|
||||
'settings.crashReporting' => 'Raportowanie błędów',
|
||||
'settings.crashReportingDescription' => 'Wysyłaj raporty o błędach, aby pomóc ulepszyć aplikację',
|
||||
'settings.debugLogging' => 'Logowanie debugowania',
|
||||
@@ -2650,9 +2652,9 @@ extension on TranslationsPl {
|
||||
'messages.markedAsUnwatchedOffline' => 'Oznaczono jako nieobejrzane (zsynchronizuje się po połączeniu)',
|
||||
'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.removedFromContinueWatching' => 'Usunięto z kontynuowania oglądania',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'messages.removedFromContinueWatching' => 'Usunięto z kontynuowania oglądania',
|
||||
'messages.errorLoading' => ({required Object error}) => 'Błąd: ${error}',
|
||||
'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.',
|
||||
@@ -3164,9 +3166,9 @@ extension on TranslationsPl {
|
||||
'watchTogether.participantSeeked' => ({required Object name}) => '${name} przewinął',
|
||||
'watchTogether.participantBuffering' => ({required Object name}) => '${name} buforuje',
|
||||
'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} używa starszej wersji aplikacji — synchronizacja jest niedostępna',
|
||||
'watchTogether.resumingWithout' => ({required Object name}) => 'Wznawianie bez ${name}',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.resumingWithout' => ({required Object name}) => 'Wznawianie bez ${name}',
|
||||
'watchTogether.waitingForParticipants' => 'Oczekiwanie na załadowanie u innych...',
|
||||
'watchTogether.waitingForName' => ({required Object name}) => 'Oczekiwanie na ${name}...',
|
||||
'watchTogether.recentRooms' => 'Ostatnie pokoje',
|
||||
|
||||
@@ -311,6 +311,7 @@ class _TranslationsSettingsPt extends TranslationsSettingsEn {
|
||||
@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 watchTogetherRelayHint => 'https://meu-relay.exemplo.com.br';
|
||||
@override String get watchTogetherRelayInvalid => '';
|
||||
@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 debugLogging => 'Log de Depuração';
|
||||
@@ -2303,6 +2304,7 @@ extension on TranslationsPt {
|
||||
'settings.watchTogetherRelay' => 'Relay do Assistir Juntos',
|
||||
'settings.watchTogetherRelayDescription' => 'Defina um relay personalizado. Todos devem usar o mesmo servidor.',
|
||||
'settings.watchTogetherRelayHint' => 'https://meu-relay.exemplo.com.br',
|
||||
'settings.watchTogetherRelayInvalid' => '',
|
||||
'settings.crashReporting' => 'Relatório de Erros',
|
||||
'settings.crashReportingDescription' => 'Enviar relatórios de erros para ajudar a melhorar o app',
|
||||
'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.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.removedFromContinueWatching' => 'Removido de Continuar Assistindo',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'messages.removedFromContinueWatching' => 'Removido de Continuar Assistindo',
|
||||
'messages.errorLoading' => ({required Object error}) => 'Erro: ${error}',
|
||||
'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.',
|
||||
@@ -3158,9 +3160,9 @@ extension on TranslationsPt {
|
||||
'watchTogether.participantSeeked' => ({required Object name}) => '${name} avançou',
|
||||
'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.resumingWithout' => ({required Object name}) => 'Retomando sem ${name}',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.resumingWithout' => ({required Object name}) => 'Retomando sem ${name}',
|
||||
'watchTogether.waitingForParticipants' => 'Aguardando outros carregarem...',
|
||||
'watchTogether.waitingForName' => ({required Object name}) => 'Aguardando ${name}...',
|
||||
'watchTogether.recentRooms' => 'Salas recentes',
|
||||
|
||||
@@ -311,6 +311,7 @@ class _TranslationsSettingsRu extends TranslationsSettingsEn {
|
||||
@override String get watchTogetherRelay => 'Relay совместного просмотра';
|
||||
@override String get watchTogetherRelayDescription => 'Задайте свой relay. Все должны использовать один сервер.';
|
||||
@override String get watchTogetherRelayHint => 'https://my-relay.example.com';
|
||||
@override String get watchTogetherRelayInvalid => '';
|
||||
@override String get crashReporting => 'Отчёты об ошибках';
|
||||
@override String get crashReportingDescription => 'Отправлять отчёты об ошибках для улучшения приложения';
|
||||
@override String get debugLogging => 'Журнал отладки';
|
||||
@@ -2309,6 +2310,7 @@ extension on TranslationsRu {
|
||||
'settings.watchTogetherRelay' => 'Relay совместного просмотра',
|
||||
'settings.watchTogetherRelayDescription' => 'Задайте свой relay. Все должны использовать один сервер.',
|
||||
'settings.watchTogetherRelayHint' => 'https://my-relay.example.com',
|
||||
'settings.watchTogetherRelayInvalid' => '',
|
||||
'settings.crashReporting' => 'Отчёты об ошибках',
|
||||
'settings.crashReportingDescription' => 'Отправлять отчёты об ошибках для улучшения приложения',
|
||||
'settings.debugLogging' => 'Журнал отладки',
|
||||
@@ -2650,9 +2652,9 @@ extension on TranslationsRu {
|
||||
'messages.markedAsUnwatchedOffline' => 'Отмечено как непросмотренное (синхронизируется при подключении)',
|
||||
'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.removedFromContinueWatching' => 'Удалено из «Продолжить просмотр»',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'messages.removedFromContinueWatching' => 'Удалено из «Продолжить просмотр»',
|
||||
'messages.errorLoading' => ({required Object error}) => 'Ошибка: ${error}',
|
||||
'messages.streamInterrupted' => 'Поток прервался. Нажмите «Воспроизвести» или перемотайте, чтобы повторить попытку.',
|
||||
'messages.liveStreamInterrupted' => 'Прямая трансляция прервалась. Нажмите «Воспроизвести», чтобы повторить попытку.',
|
||||
@@ -3164,9 +3166,9 @@ extension on TranslationsRu {
|
||||
'watchTogether.participantSeeked' => ({required Object name}) => '${name} перемотал',
|
||||
'watchTogether.participantBuffering' => ({required Object name}) => '${name} буферизует',
|
||||
'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} использует старую версию приложения — синхронизация недоступна',
|
||||
'watchTogether.resumingWithout' => ({required Object name}) => 'Возобновление без ${name}',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.resumingWithout' => ({required Object name}) => 'Возобновление без ${name}',
|
||||
'watchTogether.waitingForParticipants' => 'Ожидание загрузки у других...',
|
||||
'watchTogether.waitingForName' => ({required Object name}) => 'Ожидание ${name}...',
|
||||
'watchTogether.recentRooms' => 'Недавние комнаты',
|
||||
|
||||
@@ -311,6 +311,7 @@ class _TranslationsSettingsSv extends TranslationsSettingsEn {
|
||||
@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 watchTogetherRelayHint => 'https://min-relay.exempel.se';
|
||||
@override String get watchTogetherRelayInvalid => '';
|
||||
@override String get crashReporting => 'Kraschrapportering';
|
||||
@override String get crashReportingDescription => 'Skicka kraschrapporter för att förbättra appen';
|
||||
@override String get debugLogging => 'Felsökningsloggning';
|
||||
@@ -2303,6 +2304,7 @@ extension on TranslationsSv {
|
||||
'settings.watchTogetherRelay' => 'Titta Tillsammans-relay',
|
||||
'settings.watchTogetherRelayDescription' => 'Ange en anpassad relay. Alla måste använda samma server.',
|
||||
'settings.watchTogetherRelayHint' => 'https://min-relay.exempel.se',
|
||||
'settings.watchTogetherRelayInvalid' => '',
|
||||
'settings.crashReporting' => 'Kraschrapportering',
|
||||
'settings.crashReportingDescription' => 'Skicka kraschrapporter för att förbättra appen',
|
||||
'settings.debugLogging' => 'Felsökningsloggning',
|
||||
@@ -2644,9 +2646,9 @@ extension on TranslationsSv {
|
||||
'messages.markedAsUnwatchedOffline' => 'Markerad som osedd (synkroniseras när online)',
|
||||
'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.removedFromContinueWatching' => 'Borttagen från Fortsätt titta',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'messages.removedFromContinueWatching' => 'Borttagen från Fortsätt titta',
|
||||
'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.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.participantBuffering' => ({required Object name}) => '${name} buffrar',
|
||||
'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,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.resumingWithout' => ({required Object name}) => 'Återupptar utan ${name}',
|
||||
'watchTogether.waitingForParticipants' => 'Väntar på att andra laddar...',
|
||||
'watchTogether.waitingForName' => ({required Object name}) => 'Väntar på ${name}...',
|
||||
'watchTogether.recentRooms' => 'Senaste rum',
|
||||
|
||||
@@ -311,6 +311,7 @@ class _TranslationsSettingsZh extends TranslationsSettingsEn {
|
||||
@override String get watchTogetherRelay => '一起看中继服务器';
|
||||
@override String get watchTogetherRelayDescription => '设置自定义中继。所有人必须使用同一服务器。';
|
||||
@override String get watchTogetherRelayHint => 'https://my-relay.example.com';
|
||||
@override String get watchTogetherRelayInvalid => '';
|
||||
@override String get crashReporting => '崩溃报告';
|
||||
@override String get crashReportingDescription => '发送崩溃报告以帮助改进应用';
|
||||
@override String get debugLogging => '调试日志';
|
||||
@@ -2300,6 +2301,7 @@ extension on TranslationsZh {
|
||||
'settings.watchTogetherRelay' => '一起看中继服务器',
|
||||
'settings.watchTogetherRelayDescription' => '设置自定义中继。所有人必须使用同一服务器。',
|
||||
'settings.watchTogetherRelayHint' => 'https://my-relay.example.com',
|
||||
'settings.watchTogetherRelayInvalid' => '',
|
||||
'settings.crashReporting' => '崩溃报告',
|
||||
'settings.crashReportingDescription' => '发送崩溃报告以帮助改进应用',
|
||||
'settings.debugLogging' => '调试日志',
|
||||
@@ -2641,9 +2643,9 @@ extension on TranslationsZh {
|
||||
'messages.markedAsUnwatchedOffline' => '已标记为未观看 (将在联网时同步)',
|
||||
'messages.autoRemovedWatchedDownload' => ({required Object title}) => '已自动移除: ${title}',
|
||||
'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('zh'))(n, other: '已自动移除 ${n} 个看过的下载', ),
|
||||
'messages.removedFromContinueWatching' => '已从继续观看中移除',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'messages.removedFromContinueWatching' => '已从继续观看中移除',
|
||||
'messages.errorLoading' => ({required Object error}) => '错误: ${error}',
|
||||
'messages.streamInterrupted' => '视频流已中断。按播放键或拖动进度条重试。',
|
||||
'messages.liveStreamInterrupted' => '直播流已中断。按播放键重试。',
|
||||
@@ -3155,9 +3157,9 @@ extension on TranslationsZh {
|
||||
'watchTogether.participantSeeked' => ({required Object name}) => '${name} 跳转了',
|
||||
'watchTogether.participantBuffering' => ({required Object name}) => '${name} 正在缓冲',
|
||||
'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} 正在使用较旧版本的应用,无法同步',
|
||||
'watchTogether.resumingWithout' => ({required Object name}) => '不等待 ${name},继续播放',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.resumingWithout' => ({required Object name}) => '不等待 ${name},继续播放',
|
||||
'watchTogether.waitingForParticipants' => '等待其他人加载...',
|
||||
'watchTogether.waitingForName' => ({required Object name}) => '正在等待 ${name}...',
|
||||
'watchTogether.recentRooms' => '最近的房间',
|
||||
|
||||
@@ -180,6 +180,7 @@
|
||||
"watchTogetherRelay": "Titta Tillsammans-relay",
|
||||
"watchTogetherRelayDescription": "Ange en anpassad relay. Alla måste använda samma server.",
|
||||
"watchTogetherRelayHint": "https://min-relay.exempel.se",
|
||||
"watchTogetherRelayInvalid": "",
|
||||
"crashReporting": "Kraschrapportering",
|
||||
"crashReportingDescription": "Skicka kraschrapporter för att förbättra appen",
|
||||
"debugLogging": "Felsökningsloggning",
|
||||
|
||||
@@ -180,6 +180,7 @@
|
||||
"watchTogetherRelay": "一起看中继服务器",
|
||||
"watchTogetherRelayDescription": "设置自定义中继。所有人必须使用同一服务器。",
|
||||
"watchTogetherRelayHint": "https://my-relay.example.com",
|
||||
"watchTogetherRelayInvalid": "",
|
||||
"crashReporting": "崩溃报告",
|
||||
"crashReportingDescription": "发送崩溃报告以帮助改进应用",
|
||||
"debugLogging": "调试日志",
|
||||
|
||||
@@ -25,6 +25,8 @@ export '../services/companion_remote/lan_discovery_service.dart' show Discovered
|
||||
|
||||
typedef CommandReceivedCallback = void Function(RemoteCommand command);
|
||||
typedef PlexHomeResolver = Future<PlexHome?> Function(String connectionId);
|
||||
typedef CompanionRemotePeerServiceFactory = CompanionRemotePeerService Function();
|
||||
typedef LanDiscoveryServiceFactory = LanDiscoveryService Function();
|
||||
|
||||
String _localizedRemoteError(Object error, String Function(String details) fallback) {
|
||||
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 {
|
||||
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;
|
||||
CompanionRemotePeerService? _peerService;
|
||||
CompanionRemotePeerService? _pendingRemotePeer;
|
||||
final Expando<Future<void>> _peerDisposals = Expando<Future<void>>('companion remote peer disposal');
|
||||
LanDiscoveryService? _discoveryService;
|
||||
String _deviceName = t.companionRemote.unknownDevice;
|
||||
String _platform = 'unknown';
|
||||
@@ -44,7 +62,11 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin
|
||||
Timer? _reconnectTimer;
|
||||
int _reconnectAttempts = 0;
|
||||
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)
|
||||
List<String>? _lastHostAddresses;
|
||||
@@ -90,10 +112,6 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin
|
||||
bool get isPlayerActive => _isPlayerActive;
|
||||
bool get isHostServerRunning => _peerService?.isServerRunning ?? false;
|
||||
|
||||
CompanionRemoteProvider() {
|
||||
_initializeDeviceInfo();
|
||||
}
|
||||
|
||||
Future<void> _initializeDeviceInfo() async {
|
||||
final identity = await DeviceIdentityService.resolve();
|
||||
_deviceName = identity.deviceName ?? t.companionRemote.unknownDevice;
|
||||
@@ -420,16 +438,110 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin
|
||||
_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.
|
||||
/// Used by logout so an app-level provider surviving route replacement does
|
||||
/// not keep broadcasting with the previous Plex Home identity.
|
||||
Future<void> resetForLogout() {
|
||||
final detachedPeers = _invalidateRemoteLifecycle();
|
||||
_reconnectAttempts = 0;
|
||||
_lastHostAddresses = null;
|
||||
_lastHostClientId = null;
|
||||
_lastAuthContextId = null;
|
||||
|
||||
return _serializeLifecycle(() async {
|
||||
_reconnectTimer?.cancel();
|
||||
_reconnectAttempts = 0;
|
||||
_lastHostAddresses = null;
|
||||
_lastHostClientId = null;
|
||||
_lastAuthContextId = null;
|
||||
await _disposeDetachedPeers(detachedPeers);
|
||||
await _stopHostServerLocked();
|
||||
stopDiscovery();
|
||||
_clearCryptoContext();
|
||||
@@ -450,6 +562,12 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin
|
||||
@visibleForTesting
|
||||
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> _startHostServerLocked() async {
|
||||
@@ -461,8 +579,8 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin
|
||||
|
||||
appLogger.d('CompanionRemote: Starting host server');
|
||||
|
||||
_peerService ??= CompanionRemotePeerService();
|
||||
_setupPeerServiceListeners();
|
||||
final peer = _peerService ??= _peerServiceFactory();
|
||||
_setupPeerServiceListeners(peer, _remoteGeneration);
|
||||
|
||||
try {
|
||||
final contexts = List<RemoteAuthContext>.unmodifiable(_authContexts);
|
||||
@@ -476,7 +594,7 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin
|
||||
safeNotifyListeners();
|
||||
|
||||
// Start LAN discovery broadcasting
|
||||
_discoveryService ??= LanDiscoveryService();
|
||||
_discoveryService ??= _discoveryServiceFactory();
|
||||
final localIps = result.addresses.map((a) => a.split(':').first).toList();
|
||||
await _discoveryService!.startBroadcastingForContexts(
|
||||
contexts: contexts,
|
||||
@@ -503,19 +621,32 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin
|
||||
Future<void> stopHostServer() => _serializeLifecycle(_stopHostServerLocked);
|
||||
|
||||
Future<void> _stopHostServerLocked() async {
|
||||
_intentionalDisconnect = true;
|
||||
await _discoveryService?.stopBroadcasting();
|
||||
final stopGeneration = _remoteGeneration;
|
||||
final peer = _peerService;
|
||||
_markIntentionalDisconnect(peer, stopGeneration);
|
||||
|
||||
if (_peerService != null) {
|
||||
await _peerService!.disconnect();
|
||||
_peerService = null;
|
||||
try {
|
||||
await _discoveryService?.stopBroadcasting();
|
||||
_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() {
|
||||
@@ -524,7 +655,7 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin
|
||||
return null;
|
||||
}
|
||||
|
||||
_discoveryService ??= LanDiscoveryService();
|
||||
_discoveryService ??= _discoveryServiceFactory();
|
||||
return _discoveryService!.startListeningForContexts(_authContexts);
|
||||
}
|
||||
|
||||
@@ -543,26 +674,28 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin
|
||||
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;
|
||||
_lastHostClientId = host.clientId;
|
||||
_lastAuthContextId = authContext.id;
|
||||
|
||||
appLogger.d('CompanionRemote: Connecting to ${host.name} at ${host.addresses}');
|
||||
|
||||
_peerService = CompanionRemotePeerService();
|
||||
_setupPeerServiceListeners();
|
||||
|
||||
final candidate = _peerServiceFactory();
|
||||
_pendingRemotePeer = candidate;
|
||||
_session = RemoteSession(
|
||||
role: RemoteSessionRole.remote,
|
||||
status: RemoteSessionStatus.connecting,
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
_setupPeerServiceListeners(candidate, generation);
|
||||
safeNotifyListeners();
|
||||
|
||||
try {
|
||||
final winner = await _peerService!.joinSessionRacingWithContexts(
|
||||
final winner = await candidate.joinSessionRacingWithContexts(
|
||||
_deviceName,
|
||||
_platform,
|
||||
host.addresses,
|
||||
@@ -570,18 +703,35 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin
|
||||
authContextId: authContext.id,
|
||||
expectedHostClientId: host.clientId,
|
||||
);
|
||||
_lastHostAddresses = [winner];
|
||||
_lastAuthContextId = _peerService!.selectedAuthContextId ?? authContext.id;
|
||||
_lastHostClientId = _peerService!.selectedHostClientId ?? host.clientId;
|
||||
if (!_ownsPeer(candidate, generation)) {
|
||||
await _disposePeerOnce(candidate);
|
||||
return;
|
||||
}
|
||||
|
||||
_pendingRemotePeer = null;
|
||||
_peerService = candidate;
|
||||
_lastHostAddresses = [winner];
|
||||
_lastAuthContextId = candidate.selectedAuthContextId ?? authContext.id;
|
||||
_lastHostClientId = candidate.selectedHostClientId ?? host.clientId;
|
||||
_session = _session?.copyWith(status: RemoteSessionStatus.connected);
|
||||
safeNotifyListeners();
|
||||
appLogger.d('CompanionRemote: Connected to ${host.name} via $winner');
|
||||
} catch (e) {
|
||||
appLogger.e('CompanionRemote: Failed to connect to host', error: e);
|
||||
} catch (error, stackTrace) {
|
||||
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(
|
||||
status: RemoteSessionStatus.error,
|
||||
errorMessage: _localizedRemoteError(e, (details) => t.companionRemote.pairing.failedToConnect(error: details)),
|
||||
errorMessage: _localizedRemoteError(
|
||||
error,
|
||||
(details) => t.companionRemote.pairing.failedToConnect(error: details),
|
||||
),
|
||||
);
|
||||
safeNotifyListeners();
|
||||
rethrow;
|
||||
@@ -594,49 +744,69 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin
|
||||
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];
|
||||
_lastHostClientId = '';
|
||||
_lastAuthContextId = null;
|
||||
|
||||
appLogger.d('CompanionRemote: Connecting to manual host $hostAddress');
|
||||
|
||||
_peerService = CompanionRemotePeerService();
|
||||
_setupPeerServiceListeners();
|
||||
|
||||
final candidate = _peerServiceFactory();
|
||||
_pendingRemotePeer = candidate;
|
||||
_session = RemoteSession(
|
||||
role: RemoteSessionRole.remote,
|
||||
status: RemoteSessionStatus.connecting,
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
_setupPeerServiceListeners(candidate, generation);
|
||||
safeNotifyListeners();
|
||||
|
||||
try {
|
||||
await _peerService!.joinSessionWithContexts(_deviceName, _platform, hostAddress, _authContexts);
|
||||
_lastAuthContextId = _peerService!.selectedAuthContextId;
|
||||
_lastHostClientId = _peerService!.selectedHostClientId ?? '';
|
||||
await candidate.joinSessionWithContexts(_deviceName, _platform, hostAddress, _authContexts);
|
||||
if (!_ownsPeer(candidate, generation)) {
|
||||
await _disposePeerOnce(candidate);
|
||||
return;
|
||||
}
|
||||
|
||||
_pendingRemotePeer = null;
|
||||
_peerService = candidate;
|
||||
_lastAuthContextId = candidate.selectedAuthContextId;
|
||||
_lastHostClientId = candidate.selectedHostClientId ?? '';
|
||||
_session = _session?.copyWith(status: RemoteSessionStatus.connected);
|
||||
safeNotifyListeners();
|
||||
} catch (e) {
|
||||
appLogger.e('CompanionRemote: Failed to connect to manual host', error: e);
|
||||
} catch (error, stackTrace) {
|
||||
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(
|
||||
status: RemoteSessionStatus.error,
|
||||
errorMessage: _localizedRemoteError(e, (details) => t.companionRemote.pairing.failedToConnect(error: details)),
|
||||
errorMessage: _localizedRemoteError(
|
||||
error,
|
||||
(details) => t.companionRemote.pairing.failedToConnect(error: details),
|
||||
),
|
||||
);
|
||||
safeNotifyListeners();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
void _setupPeerServiceListeners() {
|
||||
// A rebuild/reconnect can re-enter here with live subscriptions from the
|
||||
// previous peer service still attached; drop them first so events don't
|
||||
// fan out to a stale service.
|
||||
void _setupPeerServiceListeners(CompanionRemotePeerService peer, int generation) {
|
||||
// Only one peer owns the provider's listener set at a time. The identity
|
||||
// and generation checks also reject events already queued when teardown
|
||||
// synchronously cancels these subscriptions.
|
||||
_cleanupSubscriptions();
|
||||
_commandSubscription = _peerService!.onCommandReceived.listen(
|
||||
_commandSubscription = peer.onCommandReceived.listen(
|
||||
(command) {
|
||||
if (!_ownsPeer(peer, generation)) return;
|
||||
appLogger.d('CompanionRemote: Command received: ${command.type}');
|
||||
|
||||
if (command.type == RemoteCommandType.deviceInfo) {
|
||||
@@ -649,20 +819,24 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin
|
||||
onCommandReceived?.call(command);
|
||||
}
|
||||
},
|
||||
onError: (error) {
|
||||
onError: (Object error) {
|
||||
if (!_ownsPeer(peer, generation)) return;
|
||||
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}');
|
||||
_session = _session?.copyWith(status: RemoteSessionStatus.connected, connectedDevice: device);
|
||||
safeNotifyListeners();
|
||||
});
|
||||
|
||||
_deviceDisconnectedSubscription = _peerService!.onDeviceDisconnected.listen((_) {
|
||||
appLogger.d('CompanionRemote: Device disconnected (intentional: $_intentionalDisconnect)');
|
||||
if (_intentionalDisconnect) {
|
||||
_deviceDisconnectedSubscription = peer.onDeviceDisconnected.listen((_) {
|
||||
if (!_ownsPeer(peer, generation)) return;
|
||||
final intentional = _isIntentionalDisconnect(peer, generation);
|
||||
appLogger.d('CompanionRemote: Device disconnected (intentional: $intentional)');
|
||||
if (intentional) {
|
||||
_session = _session?.copyWith(status: RemoteSessionStatus.disconnected, connectedDevice: null);
|
||||
safeNotifyListeners();
|
||||
} else if (isHost) {
|
||||
@@ -676,17 +850,19 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin
|
||||
} else {
|
||||
_session = _session?.copyWith(status: RemoteSessionStatus.reconnecting);
|
||||
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}');
|
||||
_session = _session?.copyWith(status: RemoteSessionStatus.error, errorMessage: error.message);
|
||||
safeNotifyListeners();
|
||||
});
|
||||
|
||||
_statusSubscription = _peerService!.onConnectionStateChanged.listen((status) {
|
||||
_statusSubscription = peer.onConnectionStateChanged.listen((status) {
|
||||
if (!_ownsPeer(peer, generation)) return;
|
||||
appLogger.d('CompanionRemote: Status changed: $status');
|
||||
_session = _session?.copyWith(status: status);
|
||||
safeNotifyListeners();
|
||||
@@ -740,7 +916,8 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin
|
||||
_peerService!.sendCommand(RemoteCommand(type: type, data: data));
|
||||
}
|
||||
|
||||
void _scheduleReconnect() {
|
||||
void _scheduleReconnect(int generation) {
|
||||
if (generation != _remoteGeneration || isDisposed) return;
|
||||
if (_reconnectAttempts >= _maxReconnectAttempts) {
|
||||
appLogger.w('CompanionRemote: Max reconnect attempts reached');
|
||||
_session = _session?.copyWith(
|
||||
@@ -757,22 +934,35 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin
|
||||
appLogger.d('CompanionRemote: Reconnect attempt $_reconnectAttempts in ${delay.inSeconds}s');
|
||||
|
||||
_reconnectTimer?.cancel();
|
||||
_reconnectTimer = Timer(delay, _attemptReconnect);
|
||||
_reconnectTimer = Timer(delay, () {
|
||||
if (generation != _remoteGeneration || isDisposed) return;
|
||||
unawaited(_attemptReconnect());
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _attemptReconnect() {
|
||||
final generation = _remoteGeneration;
|
||||
final active = _activeReconnect;
|
||||
if (active != null) return active;
|
||||
if (active != null && _activeReconnectGeneration == generation) {
|
||||
return active;
|
||||
}
|
||||
|
||||
late final Future<void> attempt;
|
||||
attempt = _runReconnectAttempt().whenComplete(() {
|
||||
if (identical(_activeReconnect, attempt)) _activeReconnect = null;
|
||||
attempt = _runReconnectAttempt(generation).whenComplete(() {
|
||||
if (identical(_activeReconnect, attempt)) {
|
||||
_activeReconnect = null;
|
||||
_activeReconnectGeneration = null;
|
||||
}
|
||||
});
|
||||
_activeReconnect = attempt;
|
||||
_activeReconnectGeneration = generation;
|
||||
return attempt;
|
||||
}
|
||||
|
||||
Future<void> _runReconnectAttempt() async {
|
||||
if (_lastHostAddresses == null || !isCryptoReady) {
|
||||
Future<void> _runReconnectAttempt(int generation) async {
|
||||
if (generation != _remoteGeneration || isDisposed) return;
|
||||
final hostAddresses = _lastHostAddresses;
|
||||
if (hostAddresses == null || !isCryptoReady) {
|
||||
appLogger.w('CompanionRemote: No stored context for reconnect');
|
||||
_session = _session?.copyWith(
|
||||
status: RemoteSessionStatus.error,
|
||||
@@ -782,86 +972,133 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
appLogger.d('CompanionRemote: Attempting reconnect...');
|
||||
_cleanupSubscriptions();
|
||||
try {
|
||||
await _peerService?.disconnect();
|
||||
} finally {
|
||||
_peerService = CompanionRemotePeerService();
|
||||
_setupPeerServiceListeners();
|
||||
}
|
||||
appLogger.d('CompanionRemote: Attempting reconnect...');
|
||||
final oldPeer = _peerService;
|
||||
_peerService = null;
|
||||
_cleanupSubscriptions();
|
||||
if (oldPeer != null) {
|
||||
await _disposePeerOnce(oldPeer);
|
||||
}
|
||||
if (generation != _remoteGeneration || isDisposed) return;
|
||||
|
||||
final authContextId = _authContextForId(_lastAuthContextId)?.id;
|
||||
await _peerService!.joinSessionWithContexts(
|
||||
final candidate = _peerServiceFactory();
|
||||
_pendingRemotePeer = candidate;
|
||||
_setupPeerServiceListeners(candidate, generation);
|
||||
final authContextId = _authContextForId(_lastAuthContextId)?.id;
|
||||
final expectedHostClientId = _lastHostClientId ?? '';
|
||||
|
||||
try {
|
||||
await candidate.joinSessionWithContexts(
|
||||
_deviceName,
|
||||
_platform,
|
||||
_lastHostAddresses!.first,
|
||||
hostAddresses.first,
|
||||
_authContexts,
|
||||
authContextId: authContextId,
|
||||
expectedHostClientId: _lastHostClientId ?? '',
|
||||
expectedHostClientId: expectedHostClientId,
|
||||
);
|
||||
_lastAuthContextId = _peerService!.selectedAuthContextId ?? authContextId;
|
||||
_lastHostClientId = _peerService!.selectedHostClientId ?? _lastHostClientId;
|
||||
if (!_ownsPeer(candidate, generation)) {
|
||||
await _disposePeerOnce(candidate);
|
||||
return;
|
||||
}
|
||||
|
||||
_pendingRemotePeer = null;
|
||||
_peerService = candidate;
|
||||
_lastAuthContextId = candidate.selectedAuthContextId ?? authContextId;
|
||||
_lastHostClientId = candidate.selectedHostClientId ?? _lastHostClientId;
|
||||
_session = _session?.copyWith(status: RemoteSessionStatus.connected, errorMessage: null);
|
||||
_reconnectAttempts = 0;
|
||||
safeNotifyListeners();
|
||||
appLogger.d('CompanionRemote: Reconnected successfully');
|
||||
} catch (e) {
|
||||
appLogger.e('CompanionRemote: Reconnect failed', error: e);
|
||||
if (_session?.status == RemoteSessionStatus.reconnecting) {
|
||||
_scheduleReconnect();
|
||||
} catch (error, stackTrace) {
|
||||
if (!_ownsPeer(candidate, generation)) {
|
||||
await _disposePeerOnce(candidate);
|
||||
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 = null;
|
||||
_reconnectAttempts = 0;
|
||||
_attemptReconnect();
|
||||
return _attemptReconnect();
|
||||
}
|
||||
|
||||
void cancelReconnect() {
|
||||
_reconnectTimer?.cancel();
|
||||
Future<void> cancelReconnect() async {
|
||||
final wasHost = isHost || isHostServerRunning;
|
||||
final detachedPeers = _invalidateRemoteLifecycle();
|
||||
_reconnectAttempts = 0;
|
||||
_session = _session?.copyWith(status: RemoteSessionStatus.disconnected, connectedDevice: null);
|
||||
_session = null;
|
||||
_isPlayerActive = false;
|
||||
safeNotifyListeners();
|
||||
await _disposeDetachedPeers(detachedPeers);
|
||||
if (wasHost) {
|
||||
await _serializeLifecycle(_stopHostServerLocked);
|
||||
} else {
|
||||
stopDiscovery();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> leaveSession() async {
|
||||
_intentionalDisconnect = true;
|
||||
_reconnectTimer?.cancel();
|
||||
final leavingGeneration = _remoteGeneration;
|
||||
_markIntentionalDisconnect(_peerService, leavingGeneration);
|
||||
_markIntentionalDisconnect(_pendingRemotePeer, leavingGeneration);
|
||||
final wasHost = isHost || isHostServerRunning;
|
||||
final detachedPeers = _invalidateRemoteLifecycle();
|
||||
_reconnectAttempts = 0;
|
||||
|
||||
// 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;
|
||||
}
|
||||
_session = null;
|
||||
_isPlayerActive = false;
|
||||
_intentionalDisconnect = false;
|
||||
safeNotifyListeners();
|
||||
|
||||
await _disposeDetachedPeers(detachedPeers);
|
||||
if (wasHost) {
|
||||
await _serializeLifecycle(_stopHostServerLocked);
|
||||
} else {
|
||||
stopDiscovery();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_remoteGeneration++;
|
||||
_reconnectTimer?.cancel();
|
||||
_reconnectTimer = null;
|
||||
final current = _peerService;
|
||||
final pending = _pendingRemotePeer;
|
||||
_peerService = null;
|
||||
_pendingRemotePeer = null;
|
||||
_cleanupSubscriptions();
|
||||
|
||||
_boundActiveProfile?.removeListener(_scheduleAuthContextRefresh);
|
||||
for (final sub in _profileServiceSubs) {
|
||||
sub.cancel();
|
||||
}
|
||||
_profileServiceSubs.clear();
|
||||
_discoveryService?.dispose();
|
||||
_peerService?.dispose();
|
||||
unawaited(_disposeDetachedPeers((current: current, pending: pending)));
|
||||
RemoteAuthService.instance.clearCache();
|
||||
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(
|
||||
mainAxisAlignment: .center,
|
||||
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),
|
||||
FilledButton(
|
||||
onPressed: () => provider.retryReconnectNow(),
|
||||
onPressed: () async {
|
||||
await provider.retryReconnectNow();
|
||||
},
|
||||
child: Text(t.companionRemote.remote.retryNow),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -58,7 +58,9 @@ String constrainLogUploadPayload({required String header, required String logs,
|
||||
}
|
||||
|
||||
class LogsScreen extends StatefulWidget {
|
||||
const LogsScreen({super.key});
|
||||
const LogsScreen({super.key, this.httpClient});
|
||||
|
||||
final MediaServerHttpClient? httpClient;
|
||||
|
||||
@override
|
||||
State<LogsScreen> createState() => _LogsScreenState();
|
||||
@@ -69,6 +71,8 @@ class _LogsScreenState extends State<LogsScreen> with MountedSetStateMixin {
|
||||
String _deviceInfo = '';
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
MediaServerHttpClient get _httpClient => widget.httpClient ?? httpClient;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -182,7 +186,7 @@ class _LogsScreenState extends State<LogsScreen> with MountedSetStateMixin {
|
||||
showLoadingDialog(context);
|
||||
|
||||
try {
|
||||
final response = await httpClient.post(
|
||||
final response = await _httpClient.post(
|
||||
'https://ice.plezy.app/logs',
|
||||
body: logText,
|
||||
headers: {'Content-Type': 'text/plain'},
|
||||
@@ -199,20 +203,29 @@ class _LogsScreenState extends State<LogsScreen> with MountedSetStateMixin {
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(t.messages.logsUploaded),
|
||||
content: Row(
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('${t.messages.logId}: '),
|
||||
SelectableText(
|
||||
id,
|
||||
style: const TextStyle(fontWeight: .bold, fontFamily: 'monospace', fontSize: 18),
|
||||
),
|
||||
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);
|
||||
},
|
||||
Text('${t.messages.logId}:'),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SelectableText(
|
||||
id,
|
||||
style: const TextStyle(fontWeight: .bold, fontFamily: 'monospace', fontSize: 18),
|
||||
),
|
||||
),
|
||||
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 '../../profiles/active_profile_provider.dart';
|
||||
import '../../profiles/profile.dart';
|
||||
import '../../watch_together/services/watch_together_relay_endpoint.dart';
|
||||
import 'about_screen.dart';
|
||||
import 'add_connection_screen.dart';
|
||||
import 'appearance_settings_screen.dart';
|
||||
@@ -801,6 +802,7 @@ class _RelayUrlDialog extends StatefulWidget {
|
||||
class _RelayUrlDialogState extends State<_RelayUrlDialog> {
|
||||
late final TextEditingController _controller;
|
||||
final _saveFocusNode = FocusNode(debugLabel: 'WatchTogetherRelaySave');
|
||||
bool _relayUrlInvalid = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -824,8 +826,19 @@ class _RelayUrlDialogState extends State<_RelayUrlDialog> {
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
final trimmed = _controller.text.trim();
|
||||
await widget.settingsService.write(settings.SettingsService.customRelayUrl, trimmed.isEmpty ? null : trimmed);
|
||||
final value = _controller.text;
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -835,9 +848,18 @@ class _RelayUrlDialogState extends State<_RelayUrlDialog> {
|
||||
title: Text(t.settings.watchTogetherRelay),
|
||||
content: FocusableTextField(
|
||||
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,
|
||||
textInputAction: TextInputAction.done,
|
||||
onChanged: (_) {
|
||||
if (_relayUrlInvalid) {
|
||||
setState(() => _relayUrlInvalid = false);
|
||||
}
|
||||
},
|
||||
onEditingComplete: () => _saveFocusNode.requestFocus(),
|
||||
),
|
||||
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 '../utils/platform_detector.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 }
|
||||
|
||||
@@ -216,6 +218,15 @@ String? _trimEmptyAsNull(String? v) {
|
||||
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) {
|
||||
final lines = <String>[];
|
||||
for (final item in entries) {
|
||||
@@ -431,8 +442,15 @@ class SettingsService extends BaseSharedPreferencesService {
|
||||
static const appLocale = _AppLocalePref();
|
||||
static const autoPip = _AutoPipPref();
|
||||
static const customDownloadPath = NullableStringPref('custom_download_path');
|
||||
static final customRelayUrl = NullableStringPref('custom_relay_url', transform: _trimEmptyAsNull);
|
||||
static const recentRooms = NullableStringPref('watch_together_recent_rooms');
|
||||
static final customRelayUrl = NullableStringPref('custom_relay_url', transform: _normalizeRelayBaseUrl);
|
||||
|
||||
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(
|
||||
'companion_remote_last_host_address',
|
||||
transform: _trimEmptyAsNull,
|
||||
@@ -576,6 +594,21 @@ class SettingsService extends BaseSharedPreferencesService {
|
||||
_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.
|
||||
///
|
||||
/// `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'
|
||||
if (dart.library.io) '../../utils/platform_http_client_io.dart'
|
||||
as platform;
|
||||
import '../../watch_together/services/watch_together_peer_service.dart';
|
||||
import '../../watch_together/services/watch_together_relay_endpoint.dart';
|
||||
import 'tracker_constants.dart';
|
||||
|
||||
/// 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.
|
||||
class OAuthProxyClient {
|
||||
/// 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;
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import 'dart:convert';
|
||||
|
||||
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 {
|
||||
/// Authoritative playback state broadcast by the host
|
||||
state,
|
||||
@@ -37,7 +37,7 @@ class SyncMessage {
|
||||
/// Current sync protocol version, carried on join messages. Peers with a
|
||||
/// different version are excluded from readiness gating and surfaced as
|
||||
/// needing an update.
|
||||
static const int protocolVersion = 2;
|
||||
static const int protocolVersion = 3;
|
||||
|
||||
/// Type of this message
|
||||
final SyncMessageType type;
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
int watchTogetherSystemNowMs() => DateTime.now().millisecondsSinceEpoch;
|
||||
|
||||
String watchTogetherHostPeerId(String sessionId) => 'wt-${sessionId.toUpperCase()}';
|
||||
|
||||
bool orderedStringListsEqual(List<String> first, List<String> second) {
|
||||
if (first.length != second.length) return false;
|
||||
for (var i = 0; i < first.length; i++) {
|
||||
|
||||
@@ -5,21 +5,26 @@ import 'dart:math';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../mpv/mpv.dart';
|
||||
import '../../services/settings_service.dart';
|
||||
import '../../utils/app_logger.dart';
|
||||
import '../models/playback_state.dart';
|
||||
import '../models/sync_message.dart';
|
||||
import '../models/watch_session.dart';
|
||||
import '../primitives.dart';
|
||||
import '../services/current_playback_dispatcher.dart';
|
||||
import '../services/relay_protocol.g.dart';
|
||||
import '../services/watch_together_controller.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
|
||||
/// whether the switch was handled; unhandled keys are re-dispatched on the
|
||||
/// host's next state heartbeat.
|
||||
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
|
||||
///
|
||||
/// This provider manages:
|
||||
@@ -29,9 +34,15 @@ typedef MediaSwitchCallback = Future<bool> Function(String ratingKey, ServerId s
|
||||
/// - Participant list
|
||||
/// - Media switching across the session
|
||||
class WatchTogetherProvider with ChangeNotifier {
|
||||
WatchTogetherProvider({WatchTogetherPeerServiceFactory peerServiceFactory = _createWatchTogetherPeerService})
|
||||
: _peerServiceFactory = peerServiceFactory;
|
||||
|
||||
final WatchTogetherPeerServiceFactory _peerServiceFactory;
|
||||
|
||||
WatchSession? _session;
|
||||
WatchTogetherPeerService? _peerService;
|
||||
WatchTogetherController? _controller;
|
||||
PeerError? _recoverableTransportError;
|
||||
final List<Participant> _participants = [];
|
||||
bool _isSyncing = false;
|
||||
bool _isWaitingForPeers = false;
|
||||
@@ -88,6 +99,7 @@ class WatchTogetherProvider with ChangeNotifier {
|
||||
StreamSubscription<String>? _peerDisconnectedSubscription;
|
||||
StreamSubscription<SyncMessage>? _messageSubscription;
|
||||
StreamSubscription<PeerError>? _errorSubscription;
|
||||
StreamSubscription<void>? _sessionEndedSubscription;
|
||||
|
||||
// Getters
|
||||
bool get isInSession => _session != null && _session!.state != SessionState.disconnected;
|
||||
@@ -210,9 +222,36 @@ class WatchTogetherProvider with ChangeNotifier {
|
||||
_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
|
||||
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?.onReconnected();
|
||||
};
|
||||
@@ -302,6 +341,7 @@ class WatchTogetherProvider with ChangeNotifier {
|
||||
/// Create a new watch together session as host
|
||||
Future<String> createSession({
|
||||
required ControlMode controlMode,
|
||||
required WatchTogetherRelayEndpoint relayEndpoint,
|
||||
String? displayName,
|
||||
String? sessionId,
|
||||
String? mediaRatingKey,
|
||||
@@ -314,8 +354,7 @@ class WatchTogetherProvider with ChangeNotifier {
|
||||
|
||||
appLogger.d('WatchTogether: Creating session with control mode: $controlMode');
|
||||
|
||||
final customRelayUrl = SettingsService.instanceOrNull?.read(SettingsService.customRelayUrl);
|
||||
_peerService = WatchTogetherPeerService(customBaseUrl: customRelayUrl);
|
||||
_peerService = _peerServiceFactory(endpoint: relayEndpoint);
|
||||
_setupPeerServiceListeners();
|
||||
|
||||
try {
|
||||
@@ -323,7 +362,7 @@ class WatchTogetherProvider with ChangeNotifier {
|
||||
|
||||
_session = WatchSession.createAsHost(
|
||||
sessionId: createdSessionId,
|
||||
hostPeerId: _peerService!.myPeerId!,
|
||||
hostPeerId: _peerService!.hostPeerId!,
|
||||
controlMode: controlMode,
|
||||
mediaRatingKey: mediaRatingKey,
|
||||
mediaServerId: mediaServerId,
|
||||
@@ -351,113 +390,139 @@ class WatchTogetherProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
/// 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
|
||||
await leaveSession();
|
||||
_playbackDispatcher.reset();
|
||||
|
||||
appLogger.d('WatchTogether: Joining session: $sessionId');
|
||||
|
||||
final customRelayUrl = SettingsService.instanceOrNull?.read(SettingsService.customRelayUrl);
|
||||
_peerService = WatchTogetherPeerService(customBaseUrl: customRelayUrl);
|
||||
final peerService = _peerServiceFactory(endpoint: relayEndpoint);
|
||||
_peerService = peerService;
|
||||
_setupPeerServiceListeners();
|
||||
|
||||
_session = WatchSession.joinAsGuest(sessionId: sessionId);
|
||||
final joiningSession = WatchSession.joinAsGuest(sessionId: sessionId);
|
||||
_session = joiningSession;
|
||||
notifyListeners();
|
||||
|
||||
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
|
||||
_session = _session!.copyWith(state: SessionState.connected, hostPeerId: watchTogetherHostPeerId(sessionId));
|
||||
// Host authority comes from the relay setup response rather than the
|
||||
// public room code or a client-derived routing label.
|
||||
_session = joiningSession.copyWith(state: SessionState.connected, hostPeerId: peerService.hostPeerId);
|
||||
|
||||
_displayName = displayName ?? _generateDisplayName();
|
||||
|
||||
_controller = WatchTogetherController(peerService: _peerService!, session: _session!);
|
||||
_controller = WatchTogetherController(peerService: peerService, session: _session!);
|
||||
|
||||
_wireController();
|
||||
_wireReconnectHandler();
|
||||
|
||||
// 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
|
||||
_controller!.announceJoin(_displayName);
|
||||
requestCurrentPlaybackSnapshot();
|
||||
_requestCurrentPlaybackSnapshotIfHostConnected(peerService);
|
||||
|
||||
notifyListeners();
|
||||
appLogger.d('WatchTogether: Joined session successfully');
|
||||
} catch (e) {
|
||||
appLogger.e('WatchTogether: Failed to join session', error: e);
|
||||
await leaveSession();
|
||||
if (identical(_peerService, peerService)) {
|
||||
await leaveSession();
|
||||
}
|
||||
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.
|
||||
Future<bool> enterRoom(String sessionId, {ControlMode controlMode = ControlMode.anyone, String? displayName}) async {
|
||||
// Probe the relay with a lightweight peer service to check room occupancy,
|
||||
// then do a single createSession or joinSession. This avoids the crash-prone
|
||||
// join→teardown→create cycle on the provider.
|
||||
final customRelayUrl = SettingsService.instanceOrNull?.read(SettingsService.customRelayUrl);
|
||||
final probe = WatchTogetherPeerService(customBaseUrl: customRelayUrl);
|
||||
bool shouldBeHost;
|
||||
Future<bool> enterRoom(
|
||||
String sessionId, {
|
||||
required WatchTogetherRelayEndpoint relayEndpoint,
|
||||
ControlMode controlMode = ControlMode.anyone,
|
||||
String? displayName,
|
||||
}) async {
|
||||
// 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 {
|
||||
await probe.joinSession(sessionId);
|
||||
shouldBeHost = probe.connectedPeers.isEmpty;
|
||||
} on PeerError catch (e) {
|
||||
if (e.serverCode == 'room_not_found') {
|
||||
try {
|
||||
await probe.joinSession(sessionId);
|
||||
} on PeerError catch (error) {
|
||||
if (error.serverCode != RelayProtocol.roomNotFoundCode) rethrow;
|
||||
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) {
|
||||
await createSession(controlMode: controlMode, displayName: displayName, sessionId: sessionId);
|
||||
await createSession(
|
||||
controlMode: controlMode,
|
||||
relayEndpoint: relayEndpoint,
|
||||
displayName: displayName,
|
||||
sessionId: sessionId,
|
||||
);
|
||||
} else {
|
||||
await joinSession(sessionId, displayName: displayName);
|
||||
await joinSession(sessionId, relayEndpoint: relayEndpoint, displayName: displayName);
|
||||
}
|
||||
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 {
|
||||
if (_session == null) return;
|
||||
|
||||
if (_session == null && _peerService == null) return;
|
||||
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
|
||||
_controller?.announceLeave();
|
||||
WatchTogetherPeerService? _detachLocalSession({required bool announceLeave}) {
|
||||
_recoverableTransportError = null;
|
||||
if (announceLeave) _controller?.announceLeave();
|
||||
|
||||
// Clean up subscriptions
|
||||
unawaited(_peerConnectedSubscription?.cancel());
|
||||
unawaited(_peerDisconnectedSubscription?.cancel());
|
||||
unawaited(_messageSubscription?.cancel());
|
||||
unawaited(_errorSubscription?.cancel());
|
||||
final peerService = _peerService;
|
||||
if (peerService != null) peerService.onReconnected = null;
|
||||
|
||||
_observeSubscriptionCancellation(_peerConnectedSubscription?.cancel());
|
||||
_observeSubscriptionCancellation(_peerDisconnectedSubscription?.cancel());
|
||||
_observeSubscriptionCancellation(_messageSubscription?.cancel());
|
||||
_observeSubscriptionCancellation(_errorSubscription?.cancel());
|
||||
_observeSubscriptionCancellation(_sessionEndedSubscription?.cancel());
|
||||
_peerConnectedSubscription = null;
|
||||
_peerDisconnectedSubscription = null;
|
||||
_messageSubscription = null;
|
||||
_errorSubscription = null;
|
||||
_sessionEndedSubscription = null;
|
||||
|
||||
// Cancel host reconnect grace period
|
||||
_cancelHostReconnectGracePeriod();
|
||||
_hostReconnectTimer?.cancel();
|
||||
_hostReconnectTimer = null;
|
||||
_isWaitingForHostReconnect = false;
|
||||
|
||||
// Clean up services
|
||||
_controller?.dispose();
|
||||
_controller = null;
|
||||
|
||||
await _peerService?.disconnect();
|
||||
_peerService?.dispose();
|
||||
_peerService = null;
|
||||
|
||||
_session = null;
|
||||
_participants.clear();
|
||||
_isSyncing = false;
|
||||
@@ -468,8 +533,51 @@ class WatchTogetherProvider with ChangeNotifier {
|
||||
_lastActionEventMs.clear();
|
||||
_hostIntentionallyLeft = false;
|
||||
|
||||
notifyListeners();
|
||||
appLogger.d('WatchTogether: Session left');
|
||||
if (!_disposed) notifyListeners();
|
||||
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.
|
||||
@@ -517,7 +625,9 @@ class WatchTogetherProvider with ChangeNotifier {
|
||||
|
||||
/// Set up listeners for peer service events
|
||||
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');
|
||||
|
||||
// If host reconnected during grace period, cancel the timer
|
||||
@@ -526,14 +636,15 @@ class WatchTogetherProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
if (!isHost && peerId == _session?.hostPeerId) {
|
||||
requestCurrentPlaybackSnapshot();
|
||||
_requestCurrentPlaybackSnapshotIfHostConnected(peerService);
|
||||
}
|
||||
|
||||
// Peer will announce themselves with a join message
|
||||
notifyListeners();
|
||||
});
|
||||
|
||||
_peerDisconnectedSubscription = _peerService!.onPeerDisconnected.listen((peerId) {
|
||||
_peerDisconnectedSubscription = peerService.onPeerDisconnected.listen((peerId) {
|
||||
if (_disposed || !identical(_peerService, peerService)) return;
|
||||
appLogger.d('WatchTogether: Peer disconnected: $peerId');
|
||||
|
||||
// Capture display name before removal for notification
|
||||
@@ -555,19 +666,45 @@ class WatchTogetherProvider with ChangeNotifier {
|
||||
notifyListeners();
|
||||
});
|
||||
|
||||
_messageSubscription = _peerService!.onMessageReceived.listen((message) {
|
||||
_messageSubscription = peerService.onMessageReceived.listen((message) {
|
||||
if (_disposed || !identical(_peerService, peerService)) return;
|
||||
_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}');
|
||||
|
||||
// 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) {
|
||||
_recoverableTransportError = error.originalError != null && error.serverCode == null ? error : null;
|
||||
_session = _session!.copyWith(state: SessionState.error, errorMessage: error.message);
|
||||
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
|
||||
@@ -622,7 +759,7 @@ class WatchTogetherProvider with ChangeNotifier {
|
||||
if (!isHost && message.peerId == _session?.hostPeerId) {
|
||||
_hostIntentionallyLeft = true;
|
||||
_handleHostExitedPlayer();
|
||||
leaveSession();
|
||||
_leaveSessionBestEffort('host-leave cleanup');
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
@@ -772,6 +909,7 @@ class WatchTogetherProvider with ChangeNotifier {
|
||||
if (_isWaitingForHostReconnect) {
|
||||
appLogger.d('WatchTogether: Host reconnect grace period expired');
|
||||
_isWaitingForHostReconnect = false;
|
||||
_recoverableTransportError = null;
|
||||
_session = _session?.copyWith(state: SessionState.error, errorMessage: 'Host left the session');
|
||||
onHostExitedPlayer?.call();
|
||||
notifyListeners();
|
||||
@@ -792,11 +930,18 @@ class WatchTogetherProvider with ChangeNotifier {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_cancelHostReconnectGracePeriod();
|
||||
_participantEventController.close();
|
||||
leaveSession();
|
||||
final peerService = _detachLocalSession(announceLeave: true);
|
||||
unawaited(_participantEventController.close());
|
||||
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 '../providers/watch_together_provider.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/loading_indicator_box.dart';
|
||||
|
||||
@@ -88,14 +88,18 @@ class _NotInSessionViewState extends State<_NotInSessionView> with MountedSetSta
|
||||
bool _isJoining = false;
|
||||
String? _enteringRoomCode;
|
||||
bool? _healthOk;
|
||||
String? _customRelayUrl;
|
||||
String? _profileId;
|
||||
late WatchTogetherRelayEndpoint _relayEndpoint;
|
||||
List<RecentRoom> _recentRooms = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_customRelayUrl = SettingsService.instanceOrNull?.read(SettingsService.customRelayUrl);
|
||||
_recentRooms = RecentRoomsService.getRecentRooms();
|
||||
_profileId = context.read<ActiveProfileProvider>().activeId;
|
||||
_relayEndpoint = WatchTogetherRelayEndpoint.resolve(
|
||||
SettingsService.instanceOrNull?.read(SettingsService.customRelayUrl),
|
||||
);
|
||||
_recentRooms = _loadRecentRooms();
|
||||
_checkHealth();
|
||||
}
|
||||
|
||||
@@ -103,11 +107,17 @@ class _NotInSessionViewState extends State<_NotInSessionView> with MountedSetSta
|
||||
|
||||
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 {
|
||||
final client = HttpClient();
|
||||
try {
|
||||
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(
|
||||
const Duration(seconds: 5),
|
||||
operation: 'WatchTogether health check',
|
||||
@@ -226,10 +236,19 @@ class _NotInSessionViewState extends State<_NotInSessionView> with MountedSetSta
|
||||
try {
|
||||
final sessionId = await widget.watchTogether.createSession(
|
||||
controlMode: controlMode,
|
||||
relayEndpoint: _relayEndpoint,
|
||||
displayName: _plexDisplayName,
|
||||
);
|
||||
await RecentRoomsService.addOrUpdateRoom(sessionId, controlMode: controlMode);
|
||||
setStateIfMounted(() => _recentRooms = RecentRoomsService.getRecentRooms());
|
||||
final profileId = _profileId;
|
||||
if (profileId != null && profileId.isNotEmpty) {
|
||||
await RecentRoomsService.addOrUpdateRoom(
|
||||
sessionId,
|
||||
profileId: profileId,
|
||||
endpoint: _relayEndpoint,
|
||||
controlMode: controlMode,
|
||||
);
|
||||
setStateIfMounted(() => _recentRooms = _loadRecentRooms());
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to create session', error: e);
|
||||
if (mounted) {
|
||||
@@ -271,9 +290,12 @@ class _NotInSessionViewState extends State<_NotInSessionView> with MountedSetSta
|
||||
setState(() => _isJoining = true);
|
||||
|
||||
try {
|
||||
await widget.watchTogether.joinSession(sessionId, displayName: _plexDisplayName);
|
||||
await RecentRoomsService.addOrUpdateRoom(sessionId);
|
||||
setStateIfMounted(() => _recentRooms = RecentRoomsService.getRecentRooms());
|
||||
await widget.watchTogether.joinSession(sessionId, relayEndpoint: _relayEndpoint, displayName: _plexDisplayName);
|
||||
final profileId = _profileId;
|
||||
if (profileId != null && profileId.isNotEmpty) {
|
||||
await RecentRoomsService.addOrUpdateRoom(sessionId, profileId: profileId, endpoint: _relayEndpoint);
|
||||
setStateIfMounted(() => _recentRooms = _loadRecentRooms());
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to join session', error: e);
|
||||
if (mounted) {
|
||||
@@ -292,11 +314,15 @@ class _NotInSessionViewState extends State<_NotInSessionView> with MountedSetSta
|
||||
try {
|
||||
await widget.watchTogether.enterRoom(
|
||||
room.code,
|
||||
relayEndpoint: _relayEndpoint,
|
||||
controlMode: room.controlMode ?? ControlMode.anyone,
|
||||
displayName: _plexDisplayName,
|
||||
);
|
||||
await RecentRoomsService.addOrUpdateRoom(room.code);
|
||||
setStateIfMounted(() => _recentRooms = RecentRoomsService.getRecentRooms());
|
||||
final profileId = _profileId;
|
||||
if (profileId != null && profileId.isNotEmpty) {
|
||||
await RecentRoomsService.addOrUpdateRoom(room.code, profileId: profileId, endpoint: _relayEndpoint);
|
||||
setStateIfMounted(() => _recentRooms = _loadRecentRooms());
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to enter room', error: e);
|
||||
if (mounted) {
|
||||
@@ -316,13 +342,22 @@ class _NotInSessionViewState extends State<_NotInSessionView> with MountedSetSta
|
||||
);
|
||||
if (name == null || !mounted) return;
|
||||
|
||||
await RecentRoomsService.renameRoom(room.code, name.isEmpty ? null : name);
|
||||
setStateIfMounted(() => _recentRooms = RecentRoomsService.getRecentRooms());
|
||||
final profileId = _profileId;
|
||||
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 {
|
||||
await RecentRoomsService.removeRoom(room.code);
|
||||
setStateIfMounted(() => _recentRooms = RecentRoomsService.getRecentRooms());
|
||||
final profileId = _profileId;
|
||||
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,
|
||||
);
|
||||
|
||||
if (confirmed) {
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
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 implicitJumpThresholdMs = 1500;
|
||||
static const int selfRecoveryMinBufferAheadMs = 2000;
|
||||
static const double _minimumRemoteRate = 0.25;
|
||||
static const double _maximumRemoteRate = 4.0;
|
||||
|
||||
final String myPeerId;
|
||||
final void Function(PlaybackState state, {String? toPeerId}) _sendState;
|
||||
@@ -496,7 +498,9 @@ class HostPlaybackCoordinator {
|
||||
|
||||
void _applyRemoteSeek(int targetMs, {required String actor}) {
|
||||
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);
|
||||
unawaited(
|
||||
player.seek(Duration(milliseconds: targetMs)).then((didSeek) {
|
||||
@@ -516,7 +520,7 @@ class HostPlaybackCoordinator {
|
||||
|
||||
void _applyRemoteRate(double rate, {required String actor}) {
|
||||
final player = _player;
|
||||
if (player == null) return;
|
||||
if (player == null || !rate.isFinite || rate < _minimumRemoteRate || rate > _maximumRemoteRate) return;
|
||||
_callbacks.onRemoteAction?.call(actor, PlaybackActionHint.rate);
|
||||
unawaited(
|
||||
player.setRate(rate).then((didSet) {
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../services/settings_service.dart';
|
||||
import '../models/watch_session.dart';
|
||||
import 'watch_together_relay_endpoint.dart';
|
||||
|
||||
part 'recent_rooms_service.g.dart';
|
||||
|
||||
@JsonSerializable(includeIfNull: false)
|
||||
class RecentRoom {
|
||||
final String code;
|
||||
final String relayScope;
|
||||
final String? name;
|
||||
@JsonKey(fromJson: _dateTimeFromMillis, toJson: _dateTimeToMillis)
|
||||
final DateTime lastUsed;
|
||||
@JsonKey(fromJson: _controlModeFromIndex, toJson: _controlModeToIndex)
|
||||
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);
|
||||
|
||||
@@ -24,12 +28,14 @@ class RecentRoom {
|
||||
|
||||
RecentRoom copyWith({
|
||||
String? code,
|
||||
String? relayScope,
|
||||
String? name,
|
||||
DateTime? lastUsed,
|
||||
ControlMode? controlMode,
|
||||
bool clearName = false,
|
||||
}) => RecentRoom(
|
||||
code: code ?? this.code,
|
||||
relayScope: relayScope ?? this.relayScope,
|
||||
name: clearName ? null : (name ?? this.name),
|
||||
lastUsed: lastUsed ?? this.lastUsed,
|
||||
controlMode: controlMode ?? this.controlMode,
|
||||
@@ -50,12 +56,19 @@ int? _controlModeToIndex(ControlMode? value) => value?.index;
|
||||
class RecentRoomsService {
|
||||
static const int _maxRooms = 20;
|
||||
|
||||
static List<RecentRoom> getRecentRooms() {
|
||||
final json = SettingsService.instanceOrNull?.read(SettingsService.recentRooms);
|
||||
static List<RecentRoom> getRecentRooms({required String profileId, required WatchTogetherRelayEndpoint endpoint}) {
|
||||
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 [];
|
||||
try {
|
||||
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));
|
||||
return rooms;
|
||||
} 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));
|
||||
if (rooms.length > _maxRooms) rooms.removeRange(_maxRooms, rooms.length);
|
||||
if (rooms.length > _maxRooms) {
|
||||
rooms.removeRange(_maxRooms, rooms.length);
|
||||
}
|
||||
await SettingsService.instanceOrNull?.write(
|
||||
SettingsService.recentRooms,
|
||||
jsonEncode(rooms.map((r) => r.toJson()).toList()),
|
||||
SettingsService.recentRoomsForProfile(profileId),
|
||||
jsonEncode(rooms.map((room) => room.toJson()).toList()),
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> addOrUpdateRoom(String code, {String? name, ControlMode? controlMode}) async {
|
||||
final rooms = getRecentRooms();
|
||||
final index = rooms.indexWhere((r) => r.code == code);
|
||||
static Future<void> addOrUpdateRoom(
|
||||
String 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) {
|
||||
rooms[index] = rooms[index].copyWith(
|
||||
lastUsed: DateTime.now(),
|
||||
@@ -82,23 +104,41 @@ class RecentRoomsService {
|
||||
controlMode: controlMode,
|
||||
);
|
||||
} 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 {
|
||||
final rooms = getRecentRooms();
|
||||
rooms.removeWhere((r) => r.code == code);
|
||||
await _save(rooms);
|
||||
static Future<void> removeRoom(
|
||||
String code, {
|
||||
required String profileId,
|
||||
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 {
|
||||
final rooms = getRecentRooms();
|
||||
final index = rooms.indexWhere((r) => r.code == code);
|
||||
static Future<void> renameRoom(
|
||||
String 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) {
|
||||
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(
|
||||
code: json['code'] as String,
|
||||
relayScope: json['relayScope'] as String,
|
||||
name: json['name'] as String?,
|
||||
lastUsed: _dateTimeFromMillis((json['lastUsed'] 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) =>
|
||||
<String, dynamic>{
|
||||
'code': instance.code,
|
||||
'relayScope': instance.relayScope,
|
||||
'name': ?instance.name,
|
||||
'lastUsed': _dateTimeToMillis(instance.lastUsed),
|
||||
'controlMode': ?_controlModeToIndex(instance.controlMode),
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
// Generated by scripts/generate_relay_protocol.py. Do not edit.
|
||||
|
||||
abstract final class RelayProtocol {
|
||||
static const int protocolVersion = 2;
|
||||
static const int legacyProtocolVersion = 0;
|
||||
|
||||
static const String create = 'create';
|
||||
static const String join = 'join';
|
||||
static const String broadcast = 'broadcast';
|
||||
static const String sendTo = 'sendTo';
|
||||
static const String ping = 'ping';
|
||||
static const String leave = 'leave';
|
||||
static const String endSession = 'endSession';
|
||||
static const String created = 'created';
|
||||
static const String joined = 'joined';
|
||||
static const String peerJoined = 'peerJoined';
|
||||
@@ -13,6 +18,8 @@ abstract final class RelayProtocol {
|
||||
static const String message = 'message';
|
||||
static const String error = 'error';
|
||||
static const String pong = 'pong';
|
||||
static const String left = 'left';
|
||||
static const String ended = 'ended';
|
||||
static const String rateLimitedCode = 'rate_limited';
|
||||
static const String invalidMessageCode = 'invalid_message';
|
||||
static const String roomExistsCode = 'room_exists';
|
||||
@@ -20,6 +27,8 @@ abstract final class RelayProtocol {
|
||||
static const String roomFullCode = 'room_full';
|
||||
static const String notInRoomCode = 'not_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 maxMessageSize = 65536;
|
||||
|
||||
@@ -19,7 +19,7 @@ import 'watch_together_peer_service.dart';
|
||||
/// switches or other attach gaps — the player attachment is just an output
|
||||
/// 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]),
|
||||
/// guest → [GuestPlaybackReconciler] (+ [ClockSync] against the host).
|
||||
class WatchTogetherController {
|
||||
@@ -165,7 +165,11 @@ class WatchTogetherController {
|
||||
_attachedPlayer = null;
|
||||
_coordinator?.detachPlayer(exiting: exiting);
|
||||
_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)');
|
||||
}
|
||||
|
||||
@@ -231,7 +235,11 @@ class WatchTogetherController {
|
||||
_disposed = true;
|
||||
detachPlayer(exiting: true);
|
||||
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();
|
||||
_clockSync?.stop();
|
||||
|
||||
@@ -10,12 +10,17 @@ import '../../i18n/strings.g.dart';
|
||||
import '../../services/base_peer_service.dart';
|
||||
import '../../utils/app_logger.dart';
|
||||
import '../models/sync_message.dart';
|
||||
import '../primitives.dart';
|
||||
import 'relay_protocol.g.dart';
|
||||
import 'watch_together_relay_endpoint.dart';
|
||||
|
||||
// Re-export so existing callers that import from here keep working.
|
||||
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
|
||||
///
|
||||
/// This service handles:
|
||||
@@ -24,32 +29,31 @@ export '../../services/base_peer_service.dart' show PeerError, PeerErrorType;
|
||||
/// - Sending/receiving sync messages through the relay server
|
||||
/// - Reconnection on WebSocket drops
|
||||
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';
|
||||
|
||||
static String healthUrlFor(String? customBaseUrl) {
|
||||
final base = (customBaseUrl != null && customBaseUrl.trim().isNotEmpty) ? customBaseUrl.trim() : defaultBaseUrl;
|
||||
return '$base/health';
|
||||
}
|
||||
|
||||
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;
|
||||
WatchTogetherPeerService({
|
||||
WatchTogetherRelayEndpoint? endpoint,
|
||||
this.debugReconnectSetupSucceededBarrier,
|
||||
WebSocketChannel Function(Uri uri)? debugChannelFactory,
|
||||
}) : endpoint = endpoint ?? WatchTogetherRelayEndpoint.defaultEndpoint,
|
||||
_channelFactory = debugChannelFactory ?? ((uri) => WebSocketChannel.connect(uri));
|
||||
static const int _relayProtocolVersion = RelayProtocol.protocolVersion;
|
||||
|
||||
WebSocketChannel? _channel;
|
||||
StreamSubscription? _channelSubscription;
|
||||
Completer<void>? _setupCompleter;
|
||||
String? _setupRequestType;
|
||||
final Set<String> _connectedPeers = {};
|
||||
String? _sessionId;
|
||||
String? _myPeerId;
|
||||
bool _isHost = false;
|
||||
String? _reconnectToken;
|
||||
String? _hostPeerId;
|
||||
|
||||
// Stream controllers for events
|
||||
final _peerConnectedController = StreamController<String>.broadcast();
|
||||
@@ -57,6 +61,7 @@ class WatchTogetherPeerService with KeepaliveMixin {
|
||||
final _messageReceivedController = StreamController<SyncMessage>.broadcast();
|
||||
final _errorController = StreamController<PeerError>.broadcast();
|
||||
final _connectionStateController = StreamController<bool>.broadcast();
|
||||
final _sessionEndedController = StreamController<void>.broadcast();
|
||||
|
||||
// Reconnection state
|
||||
int _reconnectAttempts = 0;
|
||||
@@ -64,6 +69,9 @@ class WatchTogetherPeerService with KeepaliveMixin {
|
||||
Timer? _reconnectTimer;
|
||||
int _connectionEpoch = 0;
|
||||
bool _disposed = false;
|
||||
bool _initialSetupInProgress = false;
|
||||
bool _teardownInProgress = false;
|
||||
Future<void>? _releaseFuture;
|
||||
|
||||
/// Called after a successful reconnection so the provider can re-announce join.
|
||||
void Function()? onReconnected;
|
||||
@@ -93,12 +101,18 @@ class WatchTogetherPeerService with KeepaliveMixin {
|
||||
/// Stream of connection state changes (true = connected, false = disconnected)
|
||||
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)
|
||||
String? get sessionId => _sessionId;
|
||||
|
||||
/// This peer's ID
|
||||
String? get myPeerId => _myPeerId;
|
||||
|
||||
/// Relay-declared peer ID whose messages carry host authority.
|
||||
String? get hostPeerId => _hostPeerId;
|
||||
|
||||
/// Whether this peer is the host
|
||||
bool get isHost => _isHost;
|
||||
|
||||
@@ -115,21 +129,45 @@ class WatchTogetherPeerService with KeepaliveMixin {
|
||||
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.
|
||||
/// Returns a completer that completes when the expected response arrives.
|
||||
Future<WebSocketChannel> _connectToRelay() async {
|
||||
final uri = Uri.parse(_relayUrl);
|
||||
final channel = WebSocketChannel.connect(uri);
|
||||
Future<WebSocketChannel> _connectToRelay({Duration? timeout, String operation = 'WatchTogether connect'}) async {
|
||||
final channel = _channelFactory(endpoint.webSocketUri);
|
||||
|
||||
// Wait for the connection to be established
|
||||
await channel.ready;
|
||||
|
||||
return channel;
|
||||
try {
|
||||
final ready = channel.ready;
|
||||
if (timeout == null) {
|
||||
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.
|
||||
Future<Completer<void>> _connectAndAnnounce(String type, int epoch) async {
|
||||
final channel = await _connectToRelay();
|
||||
Future<Completer<void>> _connectAndAnnounce(
|
||||
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) {
|
||||
unawaited(channel.sink.close());
|
||||
throw StateError('Watch Together connection attempt became stale');
|
||||
@@ -145,7 +183,15 @@ class WatchTogetherPeerService with KeepaliveMixin {
|
||||
Completer<void> _announce(String type) {
|
||||
final completer = Completer<void>();
|
||||
_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;
|
||||
}
|
||||
|
||||
@@ -168,6 +214,7 @@ class WatchTogetherPeerService with KeepaliveMixin {
|
||||
if (_setupCompleter case final completer? when !completer.isCompleted) {
|
||||
completer.completeError(error);
|
||||
_setupCompleter = null;
|
||||
_setupRequestType = null;
|
||||
}
|
||||
_handleWebSocketClosed();
|
||||
},
|
||||
@@ -179,12 +226,135 @@ class WatchTogetherPeerService with KeepaliveMixin {
|
||||
const PeerError(type: PeerErrorType.connectionFailed, message: 'WebSocket closed before setup completed'),
|
||||
);
|
||||
_setupCompleter = null;
|
||||
_setupRequestType = null;
|
||||
}
|
||||
_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).
|
||||
void _handleServerMessage(String raw) {
|
||||
try {
|
||||
@@ -193,15 +363,40 @@ class WatchTogetherPeerService with KeepaliveMixin {
|
||||
|
||||
switch (type) {
|
||||
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);
|
||||
if (_setupCompleter case final completer? when !completer.isCompleted) {
|
||||
completer.complete();
|
||||
_setupCompleter = null;
|
||||
_setupRequestType = null;
|
||||
completer.complete();
|
||||
}
|
||||
|
||||
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');
|
||||
for (final peerId in peers) {
|
||||
_connectedPeers.add(peerId);
|
||||
@@ -209,8 +404,9 @@ class WatchTogetherPeerService with KeepaliveMixin {
|
||||
}
|
||||
_safeAdd(_connectionStateController, true);
|
||||
if (_setupCompleter case final completer? when !completer.isCompleted) {
|
||||
completer.complete();
|
||||
_setupCompleter = null;
|
||||
_setupRequestType = null;
|
||||
completer.complete();
|
||||
}
|
||||
|
||||
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:
|
||||
final code = msg['code'] as String? ?? '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');
|
||||
final error = PeerError(type: PeerErrorType.serverError, message: '$code: $message', serverCode: code);
|
||||
_safeAdd(_errorController, error);
|
||||
if (_setupCompleter case final completer? when !completer.isCompleted) {
|
||||
completer.completeError(error);
|
||||
_setupCompleter = null;
|
||||
_setupRequestType = null;
|
||||
completer.completeError(error);
|
||||
}
|
||||
|
||||
case RelayProtocol.pong:
|
||||
@@ -265,8 +501,13 @@ class WatchTogetherPeerService with KeepaliveMixin {
|
||||
default:
|
||||
appLogger.w('WatchTogether: Unknown server message type: $type');
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.e('WatchTogether: Failed to parse server message', error: e);
|
||||
} catch (_) {
|
||||
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.
|
||||
void _handleWebSocketClosed() {
|
||||
final channel = _channel;
|
||||
++_connectionEpoch;
|
||||
final shouldReconnect = !_initialSetupInProgress && !_teardownInProgress;
|
||||
if (shouldReconnect) ++_connectionEpoch;
|
||||
stopKeepalive();
|
||||
unawaited(_channelSubscription?.cancel());
|
||||
_channelSubscription = null;
|
||||
@@ -308,7 +550,7 @@ class WatchTogetherPeerService with KeepaliveMixin {
|
||||
_connectedPeers.clear();
|
||||
_safeAdd(_connectionStateController, false);
|
||||
|
||||
if (!_disposed && _sessionId != null) {
|
||||
if (shouldReconnect && !_disposed && _sessionId != null) {
|
||||
_attemptReconnect(_connectionEpoch);
|
||||
}
|
||||
}
|
||||
@@ -355,6 +597,8 @@ class WatchTogetherPeerService with KeepaliveMixin {
|
||||
}
|
||||
}
|
||||
|
||||
await debugReconnectSetupSucceededBarrier?.call();
|
||||
|
||||
if (_disposed || epoch != _connectionEpoch) return;
|
||||
_reconnectAttempts = 0;
|
||||
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
|
||||
///
|
||||
/// Returns the session ID that others can use to join.
|
||||
/// If [sessionId] is provided, uses that instead of generating a new one.
|
||||
Future<String> createSession({String? sessionId}) async {
|
||||
if (_channel != null) {
|
||||
if (_sessionId != null || _channel != null) {
|
||||
await disconnect();
|
||||
}
|
||||
|
||||
@@ -390,24 +689,23 @@ class WatchTogetherPeerService with KeepaliveMixin {
|
||||
}
|
||||
_isHost = true;
|
||||
_sessionId = resolvedSessionId;
|
||||
_myPeerId = watchTogetherHostPeerId(resolvedSessionId);
|
||||
_myPeerId = const Uuid().v4();
|
||||
_reconnectToken = _mintReconnectToken();
|
||||
_reconnectAttempts = 0;
|
||||
final epoch = ++_connectionEpoch;
|
||||
|
||||
try {
|
||||
final completer = await _connectAndAnnounce(RelayProtocol.create, epoch);
|
||||
|
||||
await completer.future.timeout(
|
||||
const Duration(seconds: 10),
|
||||
onTimeout: () {
|
||||
throw const PeerError(type: PeerErrorType.timeout, message: 'Timed out creating session');
|
||||
},
|
||||
await _performInitialSetup(
|
||||
RelayProtocol.create,
|
||||
epoch,
|
||||
const PeerError(type: PeerErrorType.timeout, message: 'Timed out creating session'),
|
||||
);
|
||||
|
||||
appLogger.d('WatchTogether: Session created: $_sessionId');
|
||||
return _sessionId!;
|
||||
} catch (e) {
|
||||
appLogger.e('WatchTogether: Failed to create session', error: e);
|
||||
await _bestEffortReleaseFailedSetup(e);
|
||||
await disconnect();
|
||||
rethrow;
|
||||
}
|
||||
@@ -415,7 +713,7 @@ class WatchTogetherPeerService with KeepaliveMixin {
|
||||
|
||||
/// Join an existing session as guest.
|
||||
Future<void> joinSession(String sessionId) async {
|
||||
if (_channel != null) {
|
||||
if (_sessionId != null || _channel != null) {
|
||||
await disconnect();
|
||||
}
|
||||
|
||||
@@ -430,22 +728,21 @@ class WatchTogetherPeerService with KeepaliveMixin {
|
||||
_isHost = false;
|
||||
_sessionId = resolvedSessionId;
|
||||
_myPeerId = const Uuid().v4();
|
||||
_reconnectToken = _mintReconnectToken();
|
||||
_reconnectAttempts = 0;
|
||||
final epoch = ++_connectionEpoch;
|
||||
|
||||
try {
|
||||
final completer = await _connectAndAnnounce(RelayProtocol.join, epoch);
|
||||
|
||||
await completer.future.timeout(
|
||||
const Duration(seconds: 10),
|
||||
onTimeout: () {
|
||||
throw PeerError(type: PeerErrorType.timeout, message: t.watchTogether.failedToJoin);
|
||||
},
|
||||
await _performInitialSetup(
|
||||
RelayProtocol.join,
|
||||
epoch,
|
||||
PeerError(type: PeerErrorType.timeout, message: t.watchTogether.failedToJoin),
|
||||
);
|
||||
|
||||
appLogger.d('WatchTogether: Joined session: $_sessionId');
|
||||
} catch (e) {
|
||||
appLogger.e('WatchTogether: Failed to join session', error: e);
|
||||
await _bestEffortReleaseFailedSetup(e);
|
||||
await disconnect();
|
||||
rethrow;
|
||||
}
|
||||
@@ -466,7 +763,82 @@ class WatchTogetherPeerService with KeepaliveMixin {
|
||||
_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 {
|
||||
appLogger.d('WatchTogether: Disconnecting...');
|
||||
++_connectionEpoch;
|
||||
@@ -480,14 +852,18 @@ class WatchTogetherPeerService with KeepaliveMixin {
|
||||
_channel = null;
|
||||
final setupCompleter = _setupCompleter;
|
||||
_setupCompleter = null;
|
||||
_setupRequestType = null;
|
||||
if (setupCompleter != null && !setupCompleter.isCompleted) {
|
||||
setupCompleter.completeError(StateError('Watch Together connection cancelled'));
|
||||
}
|
||||
_connectedPeers.clear();
|
||||
_sessionId = null;
|
||||
_myPeerId = null;
|
||||
_reconnectToken = null;
|
||||
_hostPeerId = null;
|
||||
_isHost = false;
|
||||
_reconnectAttempts = 0;
|
||||
_teardownInProgress = false;
|
||||
|
||||
unawaited(subscription?.cancel());
|
||||
try {
|
||||
@@ -509,5 +885,6 @@ class WatchTogetherPeerService with KeepaliveMixin {
|
||||
_messageReceivedController.close();
|
||||
_errorController.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) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
+11
-3
@@ -1,10 +1,14 @@
|
||||
{
|
||||
"protocolVersion": 2,
|
||||
"legacyProtocolVersion": 0,
|
||||
"clientMessageTypes": {
|
||||
"create": "create",
|
||||
"join": "join",
|
||||
"broadcast": "broadcast",
|
||||
"sendTo": "sendTo",
|
||||
"ping": "ping"
|
||||
"ping": "ping",
|
||||
"leave": "leave",
|
||||
"endSession": "endSession"
|
||||
},
|
||||
"serverMessageTypes": {
|
||||
"created": "created",
|
||||
@@ -13,7 +17,9 @@
|
||||
"peerLeft": "peerLeft",
|
||||
"message": "message",
|
||||
"error": "error",
|
||||
"pong": "pong"
|
||||
"pong": "pong",
|
||||
"left": "left",
|
||||
"ended": "ended"
|
||||
},
|
||||
"errorCodes": {
|
||||
"rateLimited": "rate_limited",
|
||||
@@ -22,7 +28,9 @@
|
||||
"roomNotFound": "room_not_found",
|
||||
"roomFull": "room_full",
|
||||
"notInRoom": "not_in_room",
|
||||
"alreadyInRoom": "already_in_room"
|
||||
"alreadyInRoom": "already_in_room",
|
||||
"peerIdUnavailable": "peer_id_unavailable",
|
||||
"protocolMismatch": "protocol_mismatch"
|
||||
},
|
||||
"limits": {
|
||||
"maxRoomSize": 8,
|
||||
|
||||
@@ -90,6 +90,7 @@ if python3 scripts/check_build_workflow.py &&
|
||||
python3 scripts/check_workflow_action_pins.py &&
|
||||
python3 scripts/test_check_workflow_action_pins.py &&
|
||||
python3 scripts/test_check_codegen.py &&
|
||||
python3 scripts/test_generate_relay_protocol.py &&
|
||||
python3 scripts/test_format_native.py &&
|
||||
python3 scripts/check_update_packages_workflow.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"
|
||||
GO_PATH = ROOT / "server/relay_protocol_gen.go"
|
||||
|
||||
SUPPORTED_ID_PATTERN = r"^[A-Za-z0-9_-]+$"
|
||||
|
||||
|
||||
def camel_to_pascal(value: str) -> str:
|
||||
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:
|
||||
id_pattern = validated_id_pattern(spec)
|
||||
lines = [
|
||||
"// Generated by scripts/generate_relay_protocol.py. Do not edit.",
|
||||
"",
|
||||
"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 name, value in spec[group].items():
|
||||
lines.append(f" static const String {name} = {value!r};")
|
||||
@@ -33,7 +57,7 @@ def dart_source(spec: dict) -> str:
|
||||
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) =>",
|
||||
" value.isNotEmpty && value.length <= maxSessionIdLength && _idPattern.hasMatch(value);",
|
||||
@@ -48,6 +72,7 @@ def dart_source(spec: dict) -> str:
|
||||
|
||||
|
||||
def go_source(spec: dict) -> str:
|
||||
validated_id_pattern(spec)
|
||||
lines = [
|
||||
"// Code generated by scripts/generate_relay_protocol.py. DO NOT EDIT.",
|
||||
"",
|
||||
@@ -55,6 +80,13 @@ def go_source(spec: dict) -> str:
|
||||
"",
|
||||
"const (",
|
||||
]
|
||||
lines.extend(
|
||||
[
|
||||
f"\trelayProtocolVersion = {spec['protocolVersion']}",
|
||||
f"\tlegacyRelayProtocolVersion = {spec['legacyProtocolVersion']}",
|
||||
"",
|
||||
]
|
||||
)
|
||||
protocol_constants = []
|
||||
for group in ("clientMessageTypes", "serverMessageTypes"):
|
||||
protocol_constants.extend(
|
||||
@@ -109,8 +141,10 @@ def go_source(spec: dict) -> str:
|
||||
|
||||
def main() -> None:
|
||||
spec = json.loads(SPEC_PATH.read_text(encoding="utf-8"))
|
||||
DART_PATH.write_text(dart_source(spec), encoding="utf-8")
|
||||
GO_PATH.write_text(go_source(spec), encoding="utf-8")
|
||||
dart_output = dart_source(spec)
|
||||
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__":
|
||||
|
||||
@@ -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"
|
||||
environment:
|
||||
OAUTH_BASE_URL: https://ice.plezy.app
|
||||
TRUSTED_PROXY_CIDRS: ${TRUSTED_PROXY_CIDRS:-}
|
||||
MAL_CLIENT_ID: ${MAL_CLIENT_ID:-}
|
||||
ANILIST_CLIENT_ID: ${ANILIST_CLIENT_ID:-}
|
||||
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 (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -30,7 +31,8 @@ const (
|
||||
oauthMaxSessions = 5000
|
||||
oauthStartBurst = 3
|
||||
oauthStartRateSustained = 1
|
||||
oauthSessionIDBytes = 18 // 144 bits → 24 base64url chars
|
||||
oauthBrowserStateBytes = 18 // 144 bits → 24 base64url chars
|
||||
oauthPollSecretBytes = 18 // Independently generated device capability.
|
||||
oauthPKCEVerifierLen = 64
|
||||
oauthUpstreamTimeout = 15 * time.Second
|
||||
)
|
||||
@@ -54,70 +56,88 @@ type oauthTokenResult struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// oauthSession is created by /auth/start and lives until it's consumed by a
|
||||
// successful /auth/result (which deletes the map entry) or GC'd after
|
||||
// oauthSessionTTL. The `done` channel is closed exactly once (by complete) and
|
||||
// unblocks waiters.
|
||||
// oauthSession is created by /auth/start and lives until its result is claimed
|
||||
// or it is removed by cleanup. browserState crosses the browser/provider trust
|
||||
// boundary. Only the SHA-256 digest of the device-only poll secret is retained.
|
||||
// When both locks are needed, oauthProxy.mu must be acquired before s.mu.
|
||||
type oauthSession struct {
|
||||
id string
|
||||
browserState string
|
||||
pollDigest [sha256.Size]byte
|
||||
service string
|
||||
codeVerifier string // MAL PKCE; empty for AniList. Cleared after token exchange.
|
||||
createdAt time.Time
|
||||
done chan struct{}
|
||||
|
||||
mu sync.Mutex
|
||||
result *oauthTokenResult
|
||||
mu sync.Mutex
|
||||
completed bool
|
||||
result *oauthTokenResult
|
||||
|
||||
// Test seam used to deterministically seat concurrent result waiters.
|
||||
waitStarted func()
|
||||
}
|
||||
|
||||
func (s *oauthSession) complete(r oauthTokenResult) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.result != nil {
|
||||
return
|
||||
// completeLocked publishes at most one terminal result. The caller holds s.mu.
|
||||
func (s *oauthSession) completeLocked(r oauthTokenResult) bool {
|
||||
if s.completed {
|
||||
return false
|
||||
}
|
||||
s.completed = true
|
||||
s.result = &r
|
||||
s.codeVerifier = "" // Secret, not needed after exchange.
|
||||
close(s.done)
|
||||
return true
|
||||
}
|
||||
|
||||
// wait blocks until the session is completed or ctx is cancelled.
|
||||
func (s *oauthSession) wait(ctx context.Context) (*oauthTokenResult, error) {
|
||||
// wait blocks only until the session is ready or ctx is cancelled. Result
|
||||
// ownership is transferred separately by oauthProxy.claimResult.
|
||||
func (s *oauthSession) wait(ctx context.Context) error {
|
||||
if s.waitStarted != nil {
|
||||
s.waitStarted()
|
||||
}
|
||||
select {
|
||||
case <-s.done:
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.result, nil
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
type oauthProxy struct {
|
||||
baseURL string // e.g. https://ice.plezy.app
|
||||
services map[string]oauthServiceConfig
|
||||
client *http.Client
|
||||
func (s *oauthSession) pkceVerifier() string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.codeVerifier
|
||||
}
|
||||
|
||||
mu sync.Mutex
|
||||
sessions map[string]*oauthSession
|
||||
type oauthProxy struct {
|
||||
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
|
||||
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{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
services: services,
|
||||
client: &http.Client{Timeout: oauthUpstreamTimeout},
|
||||
sessions: make(map[string]*oauthSession),
|
||||
ipRate: make(map[string]*rateLimiter),
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
services: services,
|
||||
client: &http.Client{Timeout: oauthUpstreamTimeout},
|
||||
clientIPs: clientIPs,
|
||||
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
|
||||
// environment. Returns (nil, false) if OAUTH_BASE_URL is unset — the caller
|
||||
// wires this as "OAuth disabled, endpoints return 503".
|
||||
func oauthConfigFromEnv() (*oauthProxy, bool) {
|
||||
func oauthConfigFromEnv(clientIPs clientIPResolver) (*oauthProxy, bool) {
|
||||
base := os.Getenv("OAUTH_BASE_URL")
|
||||
if base == "" {
|
||||
return nil, false
|
||||
@@ -140,7 +160,7 @@ func oauthConfigFromEnv() (*oauthProxy, bool) {
|
||||
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
|
||||
@@ -180,13 +200,18 @@ func (p *oauthProxy) handleAuthRoot(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// 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) {
|
||||
w.Header().Set("Cache-Control", "no-store, private")
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
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) {
|
||||
http.Error(w, "Rate limited", http.StatusTooManyRequests)
|
||||
return
|
||||
@@ -205,36 +230,47 @@ func (p *oauthProxy) handleStart(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Generate tokens outside the map lock — crypto/rand syscalls would
|
||||
// otherwise serialize concurrent /auth/start calls.
|
||||
sess := &oauthSession{
|
||||
id: randToken(oauthSessionIDBytes),
|
||||
service: body.Service,
|
||||
createdAt: time.Now(),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
if cfg.UsePKCE {
|
||||
sess.codeVerifier = randPKCEVerifier()
|
||||
}
|
||||
// Generate independent trust-domain values outside the map lock —
|
||||
// crypto/rand syscalls must not serialize concurrent /auth/start calls.
|
||||
var pollSecret string
|
||||
var sess *oauthSession
|
||||
for {
|
||||
pollSecret = randToken(oauthPollSecretBytes)
|
||||
sess = &oauthSession{
|
||||
browserState: randToken(oauthBrowserStateBytes),
|
||||
pollDigest: digestPollSecret(pollSecret),
|
||||
service: body.Service,
|
||||
createdAt: time.Now(),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
if cfg.UsePKCE {
|
||||
sess.codeVerifier = randPKCEVerifier()
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
if len(p.sessions) >= oauthMaxSessions {
|
||||
p.mu.Lock()
|
||||
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()
|
||||
http.Error(w, "Server busy", http.StatusServiceUnavailable)
|
||||
return
|
||||
break
|
||||
}
|
||||
p.sessions[sess.id] = sess
|
||||
p.mu.Unlock()
|
||||
|
||||
resp := map[string]any{
|
||||
"session": sess.id,
|
||||
"url": fmt.Sprintf("%s/auth/%s?session=%s", p.baseURL, url.PathEscape(body.Service), url.QueryEscape(sess.id)),
|
||||
"session": pollSecret,
|
||||
"url": fmt.Sprintf("%s/auth/%s?state=%s", p.baseURL, url.PathEscape(body.Service), url.QueryEscape(sess.browserState)),
|
||||
"expiresIn": int(oauthSessionTTL.Seconds()),
|
||||
}
|
||||
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) {
|
||||
if r.Method != http.MethodGet {
|
||||
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)
|
||||
return
|
||||
}
|
||||
sessionID := r.URL.Query().Get("session")
|
||||
browserState := r.URL.Query().Get("state")
|
||||
p.mu.Lock()
|
||||
sess := p.sessions[sessionID]
|
||||
sess := p.browserStates[browserState]
|
||||
p.mu.Unlock()
|
||||
if sess == nil || sess.service != service {
|
||||
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"},
|
||||
"client_id": {cfg.ClientID},
|
||||
"redirect_uri": {p.redirectURI(service)},
|
||||
"state": {sess.id},
|
||||
"state": {sess.browserState},
|
||||
}
|
||||
if cfg.Scopes != "" {
|
||||
q.Set("scope", cfg.Scopes)
|
||||
}
|
||||
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)
|
||||
}
|
||||
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()
|
||||
state := q.Get("state")
|
||||
p.mu.Lock()
|
||||
sess := p.sessions[state]
|
||||
sess := p.browserStates[state]
|
||||
p.mu.Unlock()
|
||||
if sess == nil || sess.service != service {
|
||||
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 != "" {
|
||||
sess.complete(oauthTokenResult{Error: upstreamErr})
|
||||
renderErrorPage(w, http.StatusOK, "Sign-in was cancelled.")
|
||||
publicError := "authorization_failed"
|
||||
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
|
||||
}
|
||||
code := q.Get("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.")
|
||||
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)
|
||||
if err != nil {
|
||||
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.")
|
||||
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)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
w.Header().Set("Cache-Control", "no-store, private")
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
sessionID := r.URL.Query().Get("session")
|
||||
pollDigest := digestPollSecret(r.URL.Query().Get("session"))
|
||||
p.mu.Lock()
|
||||
sess := p.sessions[sessionID]
|
||||
sess := p.pollDigests[pollDigest]
|
||||
p.mu.Unlock()
|
||||
if sess == nil {
|
||||
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)
|
||||
defer cancel()
|
||||
result, err := sess.wait(ctx)
|
||||
if err != nil {
|
||||
if err := sess.wait(ctx); err != nil {
|
||||
// Client should retry — session may still receive its callback.
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
// Session consumed — delete so a retry sees 410 instead of racing another wait.
|
||||
p.mu.Lock()
|
||||
delete(p.sessions, sess.id)
|
||||
p.mu.Unlock()
|
||||
|
||||
result, ok := p.claimResult(pollDigest, sess)
|
||||
if !ok {
|
||||
http.Error(w, "Session not found", http.StatusGone)
|
||||
return
|
||||
}
|
||||
if result.Error != "" {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"error": result.Error})
|
||||
return
|
||||
@@ -369,7 +423,7 @@ func (p *oauthProxy) exchangeCode(ctx context.Context, cfg oauthServiceConfig, s
|
||||
form.Set("client_secret", cfg.ClientSecret)
|
||||
}
|
||||
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()))
|
||||
@@ -410,13 +464,59 @@ func (p *oauthProxy) redirectURI(service string) string {
|
||||
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.
|
||||
func (p *oauthProxy) cleanup() {
|
||||
now := time.Now()
|
||||
p.mu.Lock()
|
||||
for id, sess := range p.sessions {
|
||||
for _, sess := range p.browserStates {
|
||||
if now.Sub(sess.createdAt) > oauthSessionTTL {
|
||||
delete(p.sessions, id)
|
||||
p.removeSessionLocked(sess)
|
||||
}
|
||||
}
|
||||
p.mu.Unlock()
|
||||
@@ -463,6 +563,10 @@ func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func digestPollSecret(secret string) [sha256.Size]byte {
|
||||
return sha256.Sum256([]byte(secret))
|
||||
}
|
||||
|
||||
func randToken(numBytes int) string {
|
||||
b := make([]byte, numBytes)
|
||||
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,
|
||||
// with `mal` and `anilist` services pointed at a shared mock upstream.
|
||||
type oauthHarness struct {
|
||||
proxy *oauthProxy
|
||||
srv *httptest.Server
|
||||
base string
|
||||
proxy *oauthProxy
|
||||
srv *httptest.Server
|
||||
base string
|
||||
upstream *mockUpstream
|
||||
}
|
||||
|
||||
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()
|
||||
up := newMockUpstream(t)
|
||||
proxy := newOAuthProxy("http://placeholder", map[string]oauthServiceConfig{
|
||||
@@ -127,7 +132,7 @@ func newOAuthHarness(t *testing.T) *oauthHarness {
|
||||
AuthorizeURL: up.srv.URL + "/oauth/authorize",
|
||||
TokenURL: up.srv.URL + "/oauth/token",
|
||||
},
|
||||
})
|
||||
}, clientIPs)
|
||||
mux := http.NewServeMux()
|
||||
registerOAuthRoutes(mux, proxy)
|
||||
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}
|
||||
}
|
||||
|
||||
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()
|
||||
body, _ := json.Marshal(map[string]string{"service": service})
|
||||
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 {
|
||||
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 {
|
||||
Session string `json:"session"`
|
||||
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 {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if out.Session == "" || out.URL == "" {
|
||||
t.Fatalf("empty session/url: %+v", out)
|
||||
parsed, err := url.Parse(out.URL)
|
||||
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 ======
|
||||
|
||||
func TestOAuthStartReturnsSessionAndURL(t *testing.T) {
|
||||
func TestOAuthStartSeparatesDeviceCapabilityFromBrowserState(t *testing.T) {
|
||||
h := newOAuthHarness(t)
|
||||
session, qr := h.startSession(t, "mal", "1.2.3.4")
|
||||
if !strings.HasPrefix(qr, h.base+"/auth/mal?session=") {
|
||||
t.Fatalf("url=%q doesn't look like the authorize start URL", qr)
|
||||
pollSecret, browserState, authorizeURL := h.startSession(t, "mal", "1.2.3.4")
|
||||
if pollSecret == browserState {
|
||||
t.Fatal("device poll capability must differ from browser state")
|
||||
}
|
||||
if !strings.Contains(qr, url.QueryEscape(session)) {
|
||||
t.Fatalf("url=%q missing session token", qr)
|
||||
if !strings.HasPrefix(authorizeURL, h.base+"/auth/mal?state=") {
|
||||
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) {
|
||||
h := newOAuthHarness(t)
|
||||
ip := "5.5.5.5"
|
||||
for i := 0; i < oauthStartBurst; i++ {
|
||||
h.startSession(t, "mal", ip) // should all succeed
|
||||
for range oauthStartBurst {
|
||||
_, _, _ = h.startSession(t, "mal", ip) // should all succeed
|
||||
}
|
||||
body, _ := json.Marshal(map[string]string{"service": "mal"})
|
||||
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) {
|
||||
h := newOAuthHarness(t)
|
||||
resp := httpGet(t, h.base+"/auth/start")
|
||||
@@ -232,10 +368,10 @@ func TestOAuthStartMethodNotAllowed(t *testing.T) {
|
||||
|
||||
func TestOAuthAuthorizeMALRedirectIncludesPKCE(t *testing.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 }}
|
||||
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 {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
@@ -254,8 +390,11 @@ func TestOAuthAuthorizeMALRedirectIncludesPKCE(t *testing.T) {
|
||||
if q.Get("response_type") != "code" {
|
||||
t.Errorf("response_type=%q", q.Get("response_type"))
|
||||
}
|
||||
if q.Get("state") != sess {
|
||||
t.Errorf("state=%q, want session %q", q.Get("state"), sess)
|
||||
if q.Get("state") != browserState {
|
||||
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" {
|
||||
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) {
|
||||
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 }}
|
||||
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 {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
@@ -286,7 +425,7 @@ func TestOAuthAuthorizeAnilistRedirectOmitsPKCE(t *testing.T) {
|
||||
|
||||
func TestOAuthAuthorizeUnknownSessionRendersError(t *testing.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()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("status=%d want 404", resp.StatusCode)
|
||||
@@ -299,9 +438,9 @@ func TestOAuthAuthorizeUnknownSessionRendersError(t *testing.T) {
|
||||
|
||||
func TestOAuthAuthorizeWrongServiceRejected(t *testing.T) {
|
||||
h := newOAuthHarness(t)
|
||||
sess, _ := h.startSession(t, "mal", "1.1.1.3")
|
||||
// Try to use the MAL session against the AniList authorize endpoint.
|
||||
resp := httpGet(t, h.base+"/auth/anilist?session="+url.QueryEscape(sess))
|
||||
_, browserState, _ := h.startSession(t, "mal", "1.1.1.3")
|
||||
// Try to use the MAL browser state against the AniList authorize endpoint.
|
||||
resp := httpGet(t, h.base+"/auth/anilist?state="+url.QueryEscape(browserState))
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("status=%d want 404", resp.StatusCode)
|
||||
@@ -310,117 +449,238 @@ func TestOAuthAuthorizeWrongServiceRejected(t *testing.T) {
|
||||
|
||||
// ====== /auth/:service/callback + /auth/result ======
|
||||
|
||||
func TestOAuthCallbackExchangesCodeAndResultReturnsTokens(t *testing.T) {
|
||||
h := newOAuthHarness(t)
|
||||
sess, _ := h.startSession(t, "mal", "2.2.2.1")
|
||||
type oauthResultResponse struct {
|
||||
status int
|
||||
cacheControl string
|
||||
body map[string]any
|
||||
err error
|
||||
}
|
||||
|
||||
resultCh := make(chan map[string]any, 1)
|
||||
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
|
||||
}()
|
||||
|
||||
// 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)
|
||||
func requestOAuthResult(rawURL string) oauthResultResponse {
|
||||
resp, err := http.Get(rawURL)
|
||||
if err != nil {
|
||||
t.Fatalf("callback: %v", err)
|
||||
return oauthResultResponse{err: err}
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("callback status=%d", resp.StatusCode)
|
||||
defer resp.Body.Close()
|
||||
out := oauthResultResponse{
|
||||
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.
|
||||
form := h.upstream.form()
|
||||
if form.Get("code") != "CODE123" {
|
||||
t.Errorf("upstream code=%q", form.Get("code"))
|
||||
}
|
||||
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"))
|
||||
}
|
||||
func TestOAuthBrowserStateCannotClaimResult(t *testing.T) {
|
||||
for _, service := range []string{"mal", "anilist"} {
|
||||
t.Run(service, func(t *testing.T) {
|
||||
h := newOAuthHarness(t)
|
||||
pollSecret, browserState, _ := h.startSession(t, service, "2.2.2.1")
|
||||
|
||||
select {
|
||||
case got := <-resultCh:
|
||||
if got["accessToken"] != "tok-abc" {
|
||||
t.Errorf("accessToken=%v want tok-abc", got["accessToken"])
|
||||
}
|
||||
if got["refreshToken"] != "ref-xyz" {
|
||||
t.Errorf("refreshToken=%v want ref-xyz", got["refreshToken"])
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("result never returned")
|
||||
browserClaim := requestOAuthResult(h.base + "/auth/result?session=" + url.QueryEscape(browserState))
|
||||
if browserClaim.err != nil {
|
||||
t.Fatalf("browser-state result request: %v", browserClaim.err)
|
||||
}
|
||||
if browserClaim.status != http.StatusGone {
|
||||
t.Fatalf("browser-state result status=%d want 410", browserClaim.status)
|
||||
}
|
||||
|
||||
callback := fmt.Sprintf("%s/auth/%s/callback?code=CODE123&state=%s", h.base, service, url.QueryEscape(browserState))
|
||||
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.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)
|
||||
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 := httpGet(t, fmt.Sprintf("%s/auth/mal/callback?code=CODE&state=%s", h.base, url.QueryEscape(browserState)))
|
||||
resp.Body.Close()
|
||||
|
||||
select {
|
||||
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")
|
||||
if resp.StatusCode != http.StatusBadGateway {
|
||||
t.Fatalf("callback status=%d want 502", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthCallbackUserCancelled(t *testing.T) {
|
||||
h := newOAuthHarness(t)
|
||||
sess, _ := h.startSession(t, "mal", "2.2.2.3")
|
||||
|
||||
resultCh := make(chan map[string]any, 1)
|
||||
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?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")
|
||||
result := requestOAuthResult(h.base + "/auth/result?session=" + url.QueryEscape(pollSecret))
|
||||
if result.status != http.StatusOK || result.body["error"] != "exchange_failed" {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ======
|
||||
|
||||
func TestOAuthCleanupExpiresOldSessions(t *testing.T) {
|
||||
func TestOAuthCleanupRemovesBothIndexesAndSuppressesStaleCompletion(t *testing.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.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.cleanup()
|
||||
|
||||
h.proxy.mu.Lock()
|
||||
_, exists := h.proxy.sessions[sess]
|
||||
_, hasBrowserState := h.proxy.browserStates[browserState]
|
||||
_, hasPollDigest := h.proxy.pollDigests[digest]
|
||||
h.proxy.mu.Unlock()
|
||||
if exists {
|
||||
t.Fatal("expired session should have been cleaned up")
|
||||
if hasBrowserState || hasPollDigest {
|
||||
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
|
||||
// cancels. The 204-after-server-timeout path takes 50s so isn't asserted.
|
||||
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)
|
||||
defer cancel()
|
||||
|
||||
+113
-10
@@ -16,27 +16,27 @@ type rateLimiter struct {
|
||||
}
|
||||
|
||||
func newRateLimiter(burst, sustained int) *rateLimiter {
|
||||
return newRateLimiterAt(burst, sustained, time.Now())
|
||||
}
|
||||
|
||||
func newRateLimiterAt(burst, sustained int, now time.Time) *rateLimiter {
|
||||
return &rateLimiter{
|
||||
tokens: float64(burst),
|
||||
maxTokens: float64(burst),
|
||||
refillRate: float64(sustained),
|
||||
lastTime: time.Now(),
|
||||
lastTime: now,
|
||||
}
|
||||
}
|
||||
|
||||
func (rl *rateLimiter) allow() bool {
|
||||
return rl.allowAt(time.Now())
|
||||
}
|
||||
|
||||
func (rl *rateLimiter) allowAt(now time.Time) bool {
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
elapsed := now.Sub(rl.lastTime).Seconds()
|
||||
rl.lastTime = now
|
||||
|
||||
rl.tokens += elapsed * rl.refillRate
|
||||
if rl.tokens > rl.maxTokens {
|
||||
rl.tokens = rl.maxTokens
|
||||
}
|
||||
|
||||
rl.refillAtLocked(now)
|
||||
if rl.tokens < 1 {
|
||||
return false
|
||||
}
|
||||
@@ -44,6 +44,27 @@ func (rl *rateLimiter) allow() bool {
|
||||
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
|
||||
// behavior: enough idle time has passed for the bucket to be full again.
|
||||
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) ---
|
||||
|
||||
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 {
|
||||
ct.mu.Lock()
|
||||
defer ct.mu.Unlock()
|
||||
@@ -137,6 +223,23 @@ func (ct *connTracker) tryCreateRoom(ip string) bool {
|
||||
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) {
|
||||
ct.mu.Lock()
|
||||
defer ct.mu.Unlock()
|
||||
|
||||
@@ -3,25 +3,34 @@
|
||||
package main
|
||||
|
||||
const (
|
||||
relayTypeCreate = "create"
|
||||
relayTypeJoin = "join"
|
||||
relayTypeBroadcast = "broadcast"
|
||||
relayTypeSendTo = "sendTo"
|
||||
relayTypePing = "ping"
|
||||
relayTypeCreated = "created"
|
||||
relayTypeJoined = "joined"
|
||||
relayTypePeerJoined = "peerJoined"
|
||||
relayTypePeerLeft = "peerLeft"
|
||||
relayTypeMessage = "message"
|
||||
relayTypeError = "error"
|
||||
relayTypePong = "pong"
|
||||
relayErrorRateLimited = "rate_limited"
|
||||
relayErrorInvalidMessage = "invalid_message"
|
||||
relayErrorRoomExists = "room_exists"
|
||||
relayErrorRoomNotFound = "room_not_found"
|
||||
relayErrorRoomFull = "room_full"
|
||||
relayErrorNotInRoom = "not_in_room"
|
||||
relayErrorAlreadyInRoom = "already_in_room"
|
||||
relayProtocolVersion = 2
|
||||
legacyRelayProtocolVersion = 0
|
||||
|
||||
relayTypeCreate = "create"
|
||||
relayTypeJoin = "join"
|
||||
relayTypeBroadcast = "broadcast"
|
||||
relayTypeSendTo = "sendTo"
|
||||
relayTypePing = "ping"
|
||||
relayTypeLeave = "leave"
|
||||
relayTypeEndSession = "endSession"
|
||||
relayTypeCreated = "created"
|
||||
relayTypeJoined = "joined"
|
||||
relayTypePeerJoined = "peerJoined"
|
||||
relayTypePeerLeft = "peerLeft"
|
||||
relayTypeMessage = "message"
|
||||
relayTypeError = "error"
|
||||
relayTypePong = "pong"
|
||||
relayTypeLeft = "left"
|
||||
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
|
||||
maxMessageSize = 65536
|
||||
|
||||
@@ -34,12 +34,25 @@ void main() {
|
||||
|
||||
test('RecentRoom preserves epoch timestamp and control mode index', () {
|
||||
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());
|
||||
expect(decoded.code, room.code);
|
||||
expect(decoded.relayScope, room.relayScope);
|
||||
expect(decoded.name, room.name);
|
||||
expect(decoded.lastUsed, lastUsed);
|
||||
expect(decoded.controlMode, ControlMode.anyone);
|
||||
@@ -47,9 +60,13 @@ void main() {
|
||||
|
||||
test('RecentRoom omits nullable fields when absent', () {
|
||||
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', () {
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'package:plezy/profiles/plex_home_service.dart';
|
||||
import 'package:plezy/profiles/profile.dart';
|
||||
import 'package:plezy/profiles/profile_connection_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/hidden_libraries_provider.dart';
|
||||
import 'package:plezy/providers/multi_server_provider.dart';
|
||||
@@ -57,11 +58,12 @@ void main() {
|
||||
final discoverProviders = <DiscoverProvider>[];
|
||||
final hiddenProviders = <HiddenLibrariesProvider>[];
|
||||
final trackerProviders = <TrackersProvider>[];
|
||||
final companionProviders = <CompanionRemoteProvider>[];
|
||||
final disposedActiveIds = <String>[];
|
||||
|
||||
addTearDown(() async {
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
await activeProfile.resetForTesting();
|
||||
activeProfile.dispose();
|
||||
multiServer.dispose();
|
||||
@@ -85,6 +87,9 @@ void main() {
|
||||
providers: [
|
||||
Provider<StorageService>.value(value: storage),
|
||||
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<MultiServerProvider>.value(value: multiServer),
|
||||
ChangeNotifierProvider<OfflineWatchSyncService>.value(value: offlineWatch),
|
||||
@@ -96,6 +101,7 @@ void main() {
|
||||
discoverProviders: discoverProviders,
|
||||
hiddenProviders: hiddenProviders,
|
||||
trackerProviders: trackerProviders,
|
||||
companionProviders: companionProviders,
|
||||
disposedActiveIds: disposedActiveIds,
|
||||
),
|
||||
),
|
||||
@@ -109,10 +115,12 @@ void main() {
|
||||
expect(discoverProviders.single.profileId, owner.id);
|
||||
expect(discoverProviders, hasLength(1));
|
||||
expect(hiddenProviders, hasLength(1));
|
||||
expect(companionProviders, hasLength(1));
|
||||
final ownerNavigator = profileNavigationRegistry.navigator;
|
||||
final ownerDiscover = discoverProviders.single;
|
||||
final ownerHidden = hiddenProviders.single;
|
||||
final ownerTrackers = trackerProviders.single;
|
||||
final ownerCompanion = companionProviders.single;
|
||||
await ownerHidden.ensureInitialized();
|
||||
expect(ownerHidden.profileId, owner.id);
|
||||
expect(ownerHidden.hiddenLibraryKeys, {'srv:owner'});
|
||||
@@ -134,6 +142,9 @@ void main() {
|
||||
expect(trackerProviders, hasLength(2));
|
||||
expect(trackerProviders.last, isNot(same(ownerTrackers)));
|
||||
expect(ownerTrackers.isDisposed, isTrue);
|
||||
expect(companionProviders, hasLength(2));
|
||||
expect(companionProviders.last, isNot(same(ownerCompanion)));
|
||||
expect(ownerCompanion.isDisposed, isTrue);
|
||||
await hiddenProviders.last.ensureInitialized();
|
||||
expect(hiddenProviders.last.profileId, kids.id);
|
||||
expect(hiddenProviders.last.hiddenLibraryKeys, {'srv:kids'});
|
||||
@@ -145,17 +156,27 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
expect(SystemShelfService().debugActiveOwner, 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 {
|
||||
const _ProfileProbeShell({
|
||||
required this.discoverProviders,
|
||||
required this.companionProviders,
|
||||
required this.hiddenProviders,
|
||||
required this.disposedActiveIds,
|
||||
required this.trackerProviders,
|
||||
});
|
||||
|
||||
final List<CompanionRemoteProvider> companionProviders;
|
||||
final List<DiscoverProvider> discoverProviders;
|
||||
final List<HiddenLibrariesProvider> hiddenProviders;
|
||||
final List<TrackersProvider> trackerProviders;
|
||||
@@ -166,6 +187,7 @@ class _ProfileProbeShell extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _ProfileProbeShellState extends State<_ProfileProbeShell> {
|
||||
CompanionRemoteProvider? _companionProvider;
|
||||
DiscoverProvider? _discoverProvider;
|
||||
HiddenLibrariesProvider? _hiddenProvider;
|
||||
TrackersProvider? _trackersProvider;
|
||||
@@ -174,6 +196,7 @@ class _ProfileProbeShellState extends State<_ProfileProbeShell> {
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_companionProvider = context.read<CompanionRemoteProvider>();
|
||||
_discoverProvider = context.read<DiscoverProvider>();
|
||||
_hiddenProvider = context.read<HiddenLibrariesProvider>();
|
||||
_trackersProvider = context.read<TrackersProvider>();
|
||||
@@ -187,6 +210,9 @@ class _ProfileProbeShellState extends State<_ProfileProbeShell> {
|
||||
if (widget.trackerProviders.isEmpty || !identical(widget.trackerProviders.last, _trackersProvider)) {
|
||||
widget.trackerProviders.add(_trackersProvider!);
|
||||
}
|
||||
if (widget.companionProviders.isEmpty || !identical(widget.companionProviders.last, _companionProvider)) {
|
||||
widget.companionProviders.add(_companionProvider!);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_test/flutter_test.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_registry.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/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', () {
|
||||
test('connectToDiscoveredHost reports localized auth failure when crypto is not ready', () async {
|
||||
final p = CompanionRemoteProvider();
|
||||
@@ -546,3 +869,264 @@ PlexHomeUser _homeUser(String uuid, {required bool admin}) {
|
||||
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 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.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/utils/app_logger.dart';
|
||||
import 'package:plezy/utils/media_server_http_client.dart';
|
||||
|
||||
void main() {
|
||||
setUpAll(() {
|
||||
LocaleSettings.setLocaleSync(AppLocale.en);
|
||||
});
|
||||
|
||||
setUp(() {
|
||||
MemoryLogOutput.clearLogs();
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
MemoryLogOutput.clearLogs();
|
||||
});
|
||||
test('log upload payload preserves the header and newest complete lines', () {
|
||||
const header = 'Plezy test device\n---\n';
|
||||
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');
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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 {
|
||||
final selectedDirectory = Directory('${temporaryDirectory.path}/selected-downloads');
|
||||
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:plezy/i18n/strings.g.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/remote_auth_context.dart';
|
||||
import 'package:plezy/services/companion_remote/remote_auth_service.dart';
|
||||
|
||||
const _ioTimeout = Duration(seconds: 5);
|
||||
|
||||
void main() {
|
||||
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 {
|
||||
final host = CompanionRemotePeerService();
|
||||
final remote = CompanionRemotePeerService();
|
||||
@@ -25,39 +454,320 @@ void main() {
|
||||
await host.dispose();
|
||||
});
|
||||
|
||||
final context = 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'],
|
||||
);
|
||||
|
||||
final session = await host.createSessionForContexts('Test Host', 'macos', [context]);
|
||||
final session = await host.createSessionForContexts('Test Host', 'macos', [_authContext]);
|
||||
await remote.joinSessionWithContexts(
|
||||
'Test Remote',
|
||||
'ios',
|
||||
'127.0.0.1:${session.port}',
|
||||
[context],
|
||||
authContextId: context.id,
|
||||
expectedHostClientId: context.clientIdentifier,
|
||||
[_authContext],
|
||||
authContextId: _authContext.id,
|
||||
expectedHostClientId: _authContext.clientIdentifier,
|
||||
);
|
||||
|
||||
final hostCommand = host.onCommandReceived.firstWhere((command) => command.type == RemoteCommandType.play);
|
||||
remote.sendCommand(const RemoteCommand(type: RemoteCommandType.play, data: {'source': 'remote'}));
|
||||
expect(
|
||||
await hostCommand.timeout(const Duration(seconds: 5)),
|
||||
await hostCommand.timeout(_ioTimeout),
|
||||
const RemoteCommand(type: RemoteCommandType.play, data: {'source': 'remote'}),
|
||||
);
|
||||
|
||||
final remoteCommand = remote.onCommandReceived.firstWhere((command) => command.type == RemoteCommandType.pause);
|
||||
host.sendCommand(const RemoteCommand(type: RemoteCommandType.pause, data: {'source': 'host'}));
|
||||
expect(
|
||||
await remoteCommand.timeout(const Duration(seconds: 5)),
|
||||
await remoteCommand.timeout(_ioTimeout),
|
||||
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('update_last_check_time', canaries[3]);
|
||||
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');
|
||||
|
||||
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('update_last_check_time')));
|
||||
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')));
|
||||
for (final canary in canaries) {
|
||||
expect(encoded, isNot(contains(canary)));
|
||||
@@ -191,6 +193,8 @@ void main() {
|
||||
'custom_download_path': {'type': 'string', 'value': '/source/device/downloads'},
|
||||
'custom_download_path_type': {'type': 'string', 'value': 'saf'},
|
||||
'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},
|
||||
},
|
||||
},
|
||||
@@ -199,7 +203,7 @@ void main() {
|
||||
);
|
||||
|
||||
expect(result.keysImported, 4);
|
||||
expect(result.keysSkipped, 6);
|
||||
expect(result.keysSkipped, 8);
|
||||
expect(prefs.getBool('enable_hardware_decoding'), isTrue);
|
||||
expect(prefs.getDouble('default_playback_speed'), 1.0);
|
||||
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_type'), 'file');
|
||||
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);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.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', () {
|
||||
test('refreshListenables updates active prefs outside the resettable surface', () async {
|
||||
final settings = await SettingsService.getInstance();
|
||||
@@ -276,4 +312,48 @@ void main() {
|
||||
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/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_relay_endpoint.dart';
|
||||
|
||||
/// Rich fake [Player] for Watch Together sync tests.
|
||||
///
|
||||
@@ -196,6 +197,9 @@ class FakeRelayHub {
|
||||
final Map<String, HubPeerService> _peers = {};
|
||||
|
||||
HubPeerService register(String peerId) {
|
||||
if (_peers.containsKey(peerId)) {
|
||||
throw StateError('Peer ID is already registered: $peerId');
|
||||
}
|
||||
final service = HubPeerService._(peerId, this);
|
||||
for (final existing in _peers.values) {
|
||||
existing._peerConnected.add(peerId);
|
||||
@@ -235,7 +239,7 @@ class FakeRelayHub {
|
||||
}
|
||||
|
||||
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 FakeRelayHub _hub;
|
||||
|
||||
@@ -16,9 +16,11 @@ class _Harness {
|
||||
FakeAsync async, {
|
||||
ControlMode controlMode = ControlMode.hostOnly,
|
||||
HostCoordinatorCallbacks callbacks = const HostCoordinatorCallbacks(),
|
||||
Duration duration = const Duration(minutes: 45),
|
||||
bool seekable = true,
|
||||
}) {
|
||||
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(
|
||||
myPeerId: 'host',
|
||||
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', () {
|
||||
fakeAsync((async) {
|
||||
final h = _Harness(async);
|
||||
|
||||
@@ -112,10 +112,11 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('SyncMessage v2 envelope', () {
|
||||
test('join carries the protocol version', () {
|
||||
group('SyncMessage v3 envelope', () {
|
||||
test('join carries sync protocol 3', () {
|
||||
final join = SyncMessage.join(peerId: 'p', displayName: 'Name', isHost: false);
|
||||
final decoded = SyncMessage.fromJson(join.toJson());
|
||||
expect(decoded.version, 3);
|
||||
expect(decoded.version, SyncMessage.protocolVersion);
|
||||
});
|
||||
|
||||
@@ -125,7 +126,7 @@ void main() {
|
||||
expect(decoded.peerId, 'p');
|
||||
});
|
||||
|
||||
test('copyWith preserves v2 payloads', () {
|
||||
test('copyWith preserves v3 payloads', () {
|
||||
final relabeled = SyncMessage.state(fullState).copyWith(peerId: 'relay-id');
|
||||
expect(relabeled.state, fullState);
|
||||
expect(relabeled.peerId, 'relay-id');
|
||||
|
||||
@@ -2,12 +2,6 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/watch_together/primitives.dart';
|
||||
|
||||
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', () {
|
||||
expect(orderedStringListsEqual(const ['a', 'b'], const ['a', 'b']), isTrue);
|
||||
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', () {
|
||||
fakeAsync((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) {
|
||||
final needsUpdate = <String>[];
|
||||
final room = _Room(async);
|
||||
room.host.onPeerNeedsUpdate = needsUpdate.add;
|
||||
|
||||
// A legacy client joins on its own connection: its join message has no
|
||||
// version field (the relay stamps the sender id, so it must really
|
||||
// connect as itself — peerId spoofing is rewritten).
|
||||
// A sync-protocol-2 client joins on its own connection. The relay
|
||||
// stamps the sender ID, so it must really connect as itself.
|
||||
final legacyService = room.hub.register('legacy');
|
||||
legacyService.sendTo(
|
||||
'host',
|
||||
@@ -239,6 +306,7 @@ void main() {
|
||||
peerId: 'legacy',
|
||||
displayName: 'Old App',
|
||||
isHost: false,
|
||||
version: 2,
|
||||
),
|
||||
);
|
||||
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', () {
|
||||
fakeAsync((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', () {
|
||||
test('rides the ordered queue: never overtakes states sent before it', () {
|
||||
fakeAsync((async) {
|
||||
|
||||
@@ -55,6 +55,20 @@ void main() {
|
||||
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 {
|
||||
@@ -69,7 +83,8 @@ Future<void> _openLeaveConfirmation(WidgetTester tester, _OverlayHarness harness
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
@@ -101,9 +116,10 @@ class _OverlayHarness {
|
||||
}
|
||||
|
||||
class _FakeWatchTogetherProvider extends WatchTogetherProvider {
|
||||
_FakeWatchTogetherProvider({required this.isHostValue});
|
||||
_FakeWatchTogetherProvider({required this.isHostValue, this.leaveError});
|
||||
|
||||
final bool isHostValue;
|
||||
final Object? leaveError;
|
||||
var leaveCalls = 0;
|
||||
var _isDisposing = false;
|
||||
|
||||
@@ -127,6 +143,8 @@ class _FakeWatchTogetherProvider extends WatchTogetherProvider {
|
||||
@override
|
||||
Future<void> leaveSession() async {
|
||||
if (!_isDisposing) leaveCalls++;
|
||||
final error = leaveError;
|
||||
if (error != null) throw error;
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,256 @@
|
||||
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: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/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';
|
||||
|
||||
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() {
|
||||
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();
|
||||
|
||||
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', () {
|
||||
test('participantEvents stream is closed after dispose', () async {
|
||||
final p = WatchTogetherProvider();
|
||||
@@ -223,5 +849,37 @@ void main() {
|
||||
await sub.cancel();
|
||||
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