fix(relay): secure reconnect and room ownership

This commit is contained in:
edde746
2026-07-24 03:46:50 +02:00
parent e0bf66eea8
commit 43a8fe020d
82 changed files with 12341 additions and 1382 deletions
+1
View File
@@ -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
+1
View File
@@ -180,6 +180,7 @@
"watchTogetherRelay": "Релей сървър за гледане заедно",
"watchTogetherRelayDescription": "Задай собствен релей сървър. Всички трябва да използват същия сървър.",
"watchTogetherRelayHint": "https://my-relay.example.com",
"watchTogetherRelayInvalid": "",
"crashReporting": "Докладване на сривове",
"crashReportingDescription": "Изпращай доклади за сривове, за да помогнеш за подобряване на приложението",
"debugLogging": "Логове за отстраняване на грешки",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -180,6 +180,7 @@
"watchTogetherRelay": "一緒に視聴リレーサーバー",
"watchTogetherRelayDescription": "カスタムリレーを設定します。全員が同じサーバーを使う必要があります。",
"watchTogetherRelayHint": "https://my-relay.example.com",
"watchTogetherRelayInvalid": "",
"crashReporting": "クラッシュレポート",
"crashReportingDescription": "アプリの改善に役立つクラッシュレポートを送信",
"debugLogging": "デバッグログ",
+1
View File
@@ -180,6 +180,7 @@
"watchTogetherRelay": "함께 보기 릴레이",
"watchTogetherRelayDescription": "사용자 지정 릴레이를 설정합니다. 모두 같은 서버를 사용해야 합니다.",
"watchTogetherRelayHint": "https://my-relay.example.com",
"watchTogetherRelayInvalid": "",
"crashReporting": "충돌 보고",
"crashReportingDescription": "앱 개선을 위해 충돌 보고서 전송",
"debugLogging": "디버그 로깅",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -180,6 +180,7 @@
"watchTogetherRelay": "Relay совместного просмотра",
"watchTogetherRelayDescription": "Задайте свой relay. Все должны использовать один сервер.",
"watchTogetherRelayHint": "https://my-relay.example.com",
"watchTogetherRelayInvalid": "",
"crashReporting": "Отчёты об ошибках",
"crashReportingDescription": "Отправлять отчёты об ошибках для улучшения приложения",
"debugLogging": "Журнал отладки",
+1 -1
View File
@@ -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
+4 -2
View File
@@ -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' => 'Скорошни стаи',
+4 -2
View File
@@ -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',
+4 -2
View File
@@ -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',
+6 -2
View File
@@ -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',
+4 -2
View File
@@ -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',
+4 -2
View File
@@ -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 lapp — 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',
+4 -2
View File
@@ -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',
+4 -2
View File
@@ -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' => '最近のルーム',
+4 -2
View File
@@ -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' => '최근 방',
+4 -2
View File
@@ -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',
+4 -2
View File
@@ -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',
+4 -2
View File
@@ -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',
+4 -2
View File
@@ -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',
+4 -2
View File
@@ -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' => 'Недавние комнаты',
+4 -2
View File
@@ -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',
+4 -2
View File
@@ -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' => '最近的房间',
+1
View File
@@ -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",
+1
View File
@@ -180,6 +180,7 @@
"watchTogetherRelay": "一起看中继服务器",
"watchTogetherRelayDescription": "设置自定义中继。所有人必须使用同一服务器。",
"watchTogetherRelayHint": "https://my-relay.example.com",
"watchTogetherRelayInvalid": "",
"crashReporting": "崩溃报告",
"crashReportingDescription": "发送崩溃报告以帮助改进应用",
"debugLogging": "调试日志",
+330 -93
View File
@@ -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() {
return _serializeLifecycle(() async {
_reconnectTimer?.cancel();
final detachedPeers = _invalidateRemoteLifecycle();
_reconnectAttempts = 0;
_lastHostAddresses = null;
_lastHostClientId = null;
_lastAuthContextId = null;
return _serializeLifecycle(() async {
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;
final stopGeneration = _remoteGeneration;
final peer = _peerService;
_markIntentionalDisconnect(peer, stopGeneration);
try {
await _discoveryService?.stopBroadcasting();
_discoveryService?.stopListening();
if (_peerService != null) {
await _peerService!.disconnect();
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;
_intentionalDisconnect = false;
}
safeNotifyListeners();
} finally {
_clearIntentionalDisconnect(peer, stopGeneration);
}
}
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...');
final oldPeer = _peerService;
_peerService = null;
_cleanupSubscriptions();
try {
await _peerService?.disconnect();
} finally {
_peerService = CompanionRemotePeerService();
_setupPeerServiceListeners();
if (oldPeer != null) {
await _disposePeerOnce(oldPeer);
}
if (generation != _remoteGeneration || isDisposed) return;
final candidate = _peerServiceFactory();
_pendingRemotePeer = candidate;
_setupPeerServiceListeners(candidate, generation);
final authContextId = _authContextForId(_lastAuthContextId)?.id;
await _peerService!.joinSessionWithContexts(
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;
}
_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),
),
],
+17 -4
View File
@@ -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,13 +203,20 @@ 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(
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),
@@ -216,6 +227,8 @@ class _LogsScreenState extends State<LogsScreen> with MountedSetStateMixin {
),
],
),
],
),
actions: [
DialogActionButton(autofocus: true, onPressed: () => Navigator.of(ctx).pop(), label: t.common.close),
],
+25 -3
View File
@@ -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
+35 -2
View File
@@ -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 -2
View File
@@ -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;
-2
View File
@@ -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);
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 {
try {
await probe.joinSession(sessionId);
shouldBeHost = probe.connectedPeers.isEmpty;
} on PeerError catch (e) {
if (e.serverCode == 'room_not_found') {
} on PeerError catch (error) {
if (error.serverCode != RelayProtocol.roomNotFoundCode) rethrow;
shouldBeHost = true;
} else {
}
if (!shouldBeHost) {
await probe.releaseSession();
}
} finally {
await probe.disconnect();
probe.dispose();
rethrow;
}
}
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);
// Wait for the connection to be established
await channel.ready;
Future<WebSocketChannel> _connectToRelay({Duration? timeout, String operation = 'WatchTogether connect'}) async {
final channel = _channelFactory(endpoint.webSocketUri);
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
View File
@@ -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,
+1
View File
@@ -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 &&
+37 -3
View File
@@ -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__":
+72
View File
@@ -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()
+149
View File
@@ -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()
}
+1
View File
@@ -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:-}
+1243 -198
View File
File diff suppressed because it is too large Load Diff
+4133 -127
View File
File diff suppressed because it is too large Load Diff
+160 -56
View File
@@ -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,62 +56,80 @@ 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
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:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (s *oauthSession) pkceVerifier() string {
s.mu.Lock()
defer s.mu.Unlock()
return s.result, nil
case <-ctx.Done():
return nil, ctx.Err()
}
return s.codeVerifier
}
type oauthProxy struct {
baseURL string // e.g. https://ice.plezy.app
services map[string]oauthServiceConfig
client *http.Client
clientIPs clientIPResolver
mu sync.Mutex
sessions map[string]*oauthSession
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),
clientIPs: clientIPs,
browserStates: make(map[string]*oauthSession),
pollDigests: make(map[[sha256.Size]byte]*oauthSession),
ipRate: make(map[string]*rateLimiter),
}
}
@@ -117,7 +137,7 @@ func newOAuthProxy(baseURL string, services map[string]oauthServiceConfig) *oaut
// 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,10 +230,15 @@ 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),
// 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{}),
@@ -218,23 +248,29 @@ func (p *oauthProxy) handleStart(w http.ResponseWriter, r *http.Request) {
}
p.mu.Lock()
if len(p.sessions) >= oauthMaxSessions {
if len(p.browserStates) >= oauthMaxSessions {
p.mu.Unlock()
http.Error(w, "Server busy", http.StatusServiceUnavailable)
return
}
p.sessions[sess.id] = sess
if p.browserStates[sess.browserState] != nil || p.pollDigests[sess.pollDigest] != nil {
p.mu.Unlock()
continue
}
p.addSessionLocked(sess)
p.mu.Unlock()
break
}
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 {
+383 -133
View File
@@ -111,6 +111,11 @@ type oauthHarness struct {
}
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))
func requestOAuthResult(rawURL string) oauthResultResponse {
resp, err := http.Get(rawURL)
if err != nil {
resultCh <- map[string]any{"_err": err.Error()}
return
return oauthResultResponse{err: err}
}
defer resp.Body.Close()
var m map[string]any
_ = json.NewDecoder(resp.Body).Decode(&m)
resultCh <- m
}()
// Hit the callback as the upstream browser would.
cbURL := fmt.Sprintf("%s/auth/mal/callback?code=CODE123&state=%s", h.base, url.QueryEscape(sess))
resp, err := http.Get(cbURL)
if err != nil {
t.Fatalf("callback: %v", err)
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
}
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")
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)
}
// Upstream should have been called with PKCE + code.
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 form.Get("code_verifier") == "" {
t.Error("upstream missing code_verifier (PKCE)")
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")
}
})
}
if form.Get("grant_type") != "authorization_code" {
t.Errorf("grant_type=%q", form.Get("grant_type"))
}
select {
case got := <-resultCh:
if got["accessToken"] != "tok-abc" {
t.Errorf("accessToken=%v want tok-abc", got["accessToken"])
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")
}
if got["refreshToken"] != "ref-xyz" {
t.Errorf("refreshToken=%v want ref-xyz", got["refreshToken"])
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 never returned")
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 TestOAuthCallbackUpstreamError(t *testing.T) {
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)
if resp.StatusCode != http.StatusBadGateway {
t.Fatalf("callback status=%d want 502", resp.StatusCode)
}
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)
}
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")
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
View File
@@ -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()
+9
View File
@@ -3,11 +3,16 @@
package main
const (
relayProtocolVersion = 2
legacyRelayProtocolVersion = 0
relayTypeCreate = "create"
relayTypeJoin = "join"
relayTypeBroadcast = "broadcast"
relayTypeSendTo = "sendTo"
relayTypePing = "ping"
relayTypeLeave = "leave"
relayTypeEndSession = "endSession"
relayTypeCreated = "created"
relayTypeJoined = "joined"
relayTypePeerJoined = "peerJoined"
@@ -15,6 +20,8 @@ const (
relayTypeMessage = "message"
relayTypeError = "error"
relayTypePong = "pong"
relayTypeLeft = "left"
relayTypeEnded = "ended"
relayErrorRateLimited = "rate_limited"
relayErrorInvalidMessage = "invalid_message"
relayErrorRoomExists = "room_exists"
@@ -22,6 +29,8 @@ const (
relayErrorRoomFull = "room_full"
relayErrorNotInRoom = "not_in_room"
relayErrorAlreadyInRoom = "already_in_room"
relayErrorPeerIdUnavailable = "peer_id_unavailable"
relayErrorProtocolMismatch = "protocol_mismatch"
maxRoomSize = 8
maxMessageSize = 65536
+21 -4
View File
@@ -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();
}
}
+124
View File
@@ -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,7 +454,110 @@ void main() {
await host.dispose();
});
final context = RemoteAuthContext(
final session = await host.createSessionForContexts('Test Host', 'macos', [_authContext]);
await remote.joinSessionWithContexts(
'Test Remote',
'ios',
'127.0.0.1:${session.port}',
[_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(_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(_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',
@@ -36,28 +568,206 @@ void main() {
allowedUserUuids: const ['user-1'],
);
final session = await host.createSessionForContexts('Test Host', 'macos', [context]);
await remote.joinSessionWithContexts(
'Test Remote',
'ios',
'127.0.0.1:${session.port}',
[context],
authContextId: context.id,
expectedHostClientId: context.clientIdentifier,
);
Future<int> _startHost(CompanionRemotePeerService host) async {
final session = await host.createSessionForContexts('Test Host', 'macos', [_authContext]);
return session.port;
}
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)),
const RemoteCommand(type: RemoteCommandType.play, data: {'source': 'remote'}),
);
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();
}
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)),
const RemoteCommand(type: RemoteCommandType.pause, data: {'source': 'host'}),
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);
});
+80
View File
@@ -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;
}
}
+5 -1
View File
@@ -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);
+4 -3
View File
@@ -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');
-6
View File
@@ -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);
});
});
}