From 23e2cc0e0751b49ce2bceca97480aafcbcd3d421 Mon Sep 17 00:00:00 2001 From: Sertac Ozercan Date: Tue, 28 Jul 2026 11:37:18 -0700 Subject: [PATCH 01/18] fix(auth): harden cookie persistence and login completion Signed-off-by: Sertac Ozercan --- Sources/Kaset/Resources/Localizable.xcstrings | 376 ++++++++ .../Resources/ar.lproj/Localizable.strings | 4 + .../Resources/de.lproj/Localizable.strings | 4 + .../Resources/en.lproj/Localizable.strings | 6 + .../Resources/es.lproj/Localizable.strings | 4 + .../Resources/fr.lproj/Localizable.strings | 4 + .../Resources/id.lproj/Localizable.strings | 4 + .../Resources/it.lproj/Localizable.strings | 4 + .../Resources/ko.lproj/Localizable.strings | 4 + .../Resources/nl.lproj/Localizable.strings | 4 + .../Resources/pl.lproj/Localizable.strings | 4 + .../Resources/pt.lproj/Localizable.strings | 4 + .../Resources/ru.lproj/Localizable.strings | 4 + .../Resources/sv.lproj/Localizable.strings | 4 + .../Resources/tr.lproj/Localizable.strings | 4 + .../Resources/uk.lproj/Localizable.strings | 4 + Sources/Kaset/Services/Auth/AuthService.swift | 365 +++++++- Sources/Kaset/Services/Protocols.swift | 111 ++- .../WebKit/AuthCookieClearCoordinator.swift | 99 +++ .../WebKitManager+CookieArchiveStorage.swift | 675 ++++++++++++++ .../WebKit/WebKitManager+CookieBackup.swift | 824 ++++++++++++++++++ .../WebKit/WebKitManager+CookieRestore.swift | 186 ++++ .../WebKit/WebKitManager+Cookies.swift | 393 +++++++-- .../Kaset/Services/WebKit/WebKitManager.swift | 334 +++---- Sources/Kaset/Views/GeneralSettingsView.swift | 21 +- Sources/Kaset/Views/LoginSheet.swift | 463 +++++++++- Sources/Kaset/Views/MainWindow.swift | 5 + Tests/KasetTests/AccountServiceTests.swift | 4 +- .../AuthServiceDurabilityTests.swift | 207 +++++ Tests/KasetTests/AuthServiceTests.swift | 270 +++++- .../Helpers/MockWebKitManager.swift | 161 +++- .../KasetTests/LoginCompletionGateTests.swift | 631 ++++++++++++++ .../KasetTests/WebKitAuthMaterialTests.swift | 70 ++ .../KasetTests/WebKitCookieRestoreTests.swift | 323 +++++++ .../WebKitLoginCookieRollbackTests.swift | 73 ++ Tests/KasetTests/WebKitManagerTests.swift | 635 ++++++++++++++ 36 files changed, 5970 insertions(+), 318 deletions(-) create mode 100644 Sources/Kaset/Services/WebKit/AuthCookieClearCoordinator.swift create mode 100644 Sources/Kaset/Services/WebKit/WebKitManager+CookieArchiveStorage.swift create mode 100644 Sources/Kaset/Services/WebKit/WebKitManager+CookieBackup.swift create mode 100644 Sources/Kaset/Services/WebKit/WebKitManager+CookieRestore.swift create mode 100644 Tests/KasetTests/AuthServiceDurabilityTests.swift create mode 100644 Tests/KasetTests/LoginCompletionGateTests.swift create mode 100644 Tests/KasetTests/WebKitCookieRestoreTests.swift create mode 100644 Tests/KasetTests/WebKitLoginCookieRollbackTests.swift diff --git a/Sources/Kaset/Resources/Localizable.xcstrings b/Sources/Kaset/Resources/Localizable.xcstrings index 4285946ca..f14547481 100644 --- a/Sources/Kaset/Resources/Localizable.xcstrings +++ b/Sources/Kaset/Resources/Localizable.xcstrings @@ -10961,6 +10961,194 @@ }, "Item %lld": {}, "Kaset": {}, + "Kaset could not safely clear saved sign-in data. Retry before signing in again.": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذّر على Kaset مسح بيانات تسجيل الدخول المحفوظة بأمان. أعد المحاولة قبل تسجيل الدخول مرة أخرى." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kaset konnte die gespeicherten Anmeldedaten nicht sicher löschen. Versuche es erneut, bevor du dich wieder anmeldest." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Kaset could not safely clear saved sign-in data. Retry before signing in again." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Kaset no pudo borrar de forma segura los datos de inicio de sesión guardados. Reinténtalo antes de volver a iniciar sesión." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Kaset n’a pas pu effacer en toute sécurité les données de connexion enregistrées. Réessayez avant de vous reconnecter." + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Kaset tidak dapat menghapus data masuk yang tersimpan dengan aman. Coba lagi sebelum masuk kembali." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Kaset non ha potuto eliminare in modo sicuro i dati di accesso salvati. Riprova prima di accedere di nuovo." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Kaset이 저장된 로그인 데이터를 안전하게 지우지 못했습니다. 다시 로그인하기 전에 재시도하세요." + } + }, + "nl": { + "stringUnit": { + "state": "translated", + "value": "Kaset kon de opgeslagen aanmeldgegevens niet veilig wissen. Probeer het opnieuw voordat je je weer aanmeldt." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Kaset nie mógł bezpiecznie usunąć zapisanych danych logowania. Spróbuj ponownie przed kolejnym logowaniem." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "O Kaset não conseguiu limpar em segurança os dados de início de sessão guardados. Tente novamente antes de iniciar sessão outra vez." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Kaset не удалось безопасно удалить сохранённые данные для входа. Повторите попытку перед новым входом." + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Kaset kunde inte rensa sparade inloggningsdata på ett säkert sätt. Försök igen innan du loggar in på nytt." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kaset kayıtlı oturum açma verilerini güvenli bir şekilde temizleyemedi. Yeniden oturum açmadan önce tekrar deneyin." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Kaset не вдалося безпечно видалити збережені дані входу. Повторіть спробу перед новим входом." + } + } + } + }, + "Kaset could not remove saved sign-in data. Try signing out again before quitting.": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذّر على Kaset إزالة بيانات تسجيل الدخول المحفوظة. حاول تسجيل الخروج مرة أخرى قبل إنهاء التطبيق." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kaset konnte die gespeicherten Anmeldedaten nicht entfernen. Versuche vor dem Beenden erneut, dich abzumelden." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Kaset could not remove saved sign-in data. Try signing out again before quitting." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Kaset no pudo eliminar los datos de inicio de sesión guardados. Intenta cerrar sesión de nuevo antes de salir." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Kaset n’a pas pu supprimer les données de connexion enregistrées. Essayez de vous déconnecter à nouveau avant de quitter." + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Kaset tidak dapat menghapus data masuk yang tersimpan. Coba keluar lagi sebelum menutup aplikasi." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Kaset non ha potuto rimuovere i dati di accesso salvati. Prova a disconnetterti di nuovo prima di uscire." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Kaset이 저장된 로그인 데이터를 제거하지 못했습니다. 앱을 종료하기 전에 다시 로그아웃해 보세요." + } + }, + "nl": { + "stringUnit": { + "state": "translated", + "value": "Kaset kon de opgeslagen aanmeldgegevens niet verwijderen. Probeer opnieuw af te melden voordat je stopt." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Kaset nie mógł usunąć zapisanych danych logowania. Spróbuj wylogować się ponownie przed zamknięciem aplikacji." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "O Kaset não conseguiu remover os dados de início de sessão guardados. Tente terminar sessão novamente antes de sair." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Kaset не удалось удалить сохранённые данные для входа. Попробуйте выйти ещё раз перед завершением работы." + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Kaset kunde inte ta bort sparade inloggningsdata. Försök logga ut igen innan du avslutar." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kaset kayıtlı oturum açma verilerini kaldıramadı. Uygulamadan çıkmadan önce yeniden çıkış yapmayı deneyin." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Kaset не вдалося видалити збережені дані входу. Спробуйте вийти ще раз перед завершенням роботи." + } + } + } + }, "Keep listening even when the window is closed": { "localizations": { "ar": { @@ -21710,6 +21898,194 @@ } } }, + "Sign-In Cleanup Required": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يلزم تنظيف تسجيل الدخول" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Anmeldebereinigung erforderlich" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign-In Cleanup Required" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Se requiere limpiar el inicio de sesión" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nettoyage de connexion requis" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Pembersihan proses masuk diperlukan" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Pulizia dell’accesso necessaria" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "로그인 정리 필요" + } + }, + "nl": { + "stringUnit": { + "state": "translated", + "value": "Aanmeldgegevens moeten worden opgeschoond" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wymagane oczyszczenie logowania" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "É necessário limpar o início de sessão" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Требуется очистка данных входа" + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Inloggningsdata måste rensas" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Oturum Açma Temizliği Gerekli" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Потрібне очищення даних входу" + } + } + } + }, + "Sign Out Incomplete": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لم يكتمل تسجيل الخروج" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Abmeldung unvollständig" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign Out Incomplete" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cierre de sesión incompleto" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Déconnexion incomplète" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Proses keluar belum selesai" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Disconnessione incompleta" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "로그아웃이 완료되지 않음" + } + }, + "nl": { + "stringUnit": { + "state": "translated", + "value": "Afmelden niet voltooid" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wylogowanie nie zostało ukończone" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Fim de sessão incompleto" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выход не завершён" + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Utloggningen slutfördes inte" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çıkış Tamamlanmadı" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Вихід не завершено" + } + } + } + }, "Signed in to YouTube": { "localizations": { "ar": { diff --git a/Sources/Kaset/Resources/ar.lproj/Localizable.strings b/Sources/Kaset/Resources/ar.lproj/Localizable.strings index f85dbc3da..05bdaecf1 100644 --- a/Sources/Kaset/Resources/ar.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/ar.lproj/Localizable.strings @@ -208,6 +208,8 @@ "Kaset creates fresh AI sessions per request. Refresh the status if Apple Intelligence finishes downloading or becomes available while the app is open." = "ينشئ Kaset جلسات ذكاء اصطناعي جديدة لكل طلب. حدّث الحالة إذا انتهى تنزيل Apple Intelligence أو أصبح متاحًا أثناء فتح التطبيق."; "Kaset works without login for public browsing, search, and playback. Sign in to access personal music collections." = "يعمل Kaset من دون تسجيل دخول للتصفح العام والبحث والتشغيل. سجّل الدخول للوصول إلى مجموعات الموسيقى الشخصية."; "Kaset works without login for public YouTube search, discovery, and playback. Sign in to access personal video collections." = "يعمل Kaset من دون تسجيل دخول للبحث العام في YouTube والاكتشاف والتشغيل. سجّل الدخول للوصول إلى مجموعات الفيديو الشخصية."; +"Kaset could not safely clear saved sign-in data. Retry before signing in again." = "تعذّر على Kaset مسح بيانات تسجيل الدخول المحفوظة بأمان. أعد المحاولة قبل تسجيل الدخول مرة أخرى."; +"Kaset could not remove saved sign-in data. Try signing out again before quitting." = "تعذّر على Kaset إزالة بيانات تسجيل الدخول المحفوظة. حاول تسجيل الخروج مرة أخرى قبل إنهاء التطبيق."; "Keep a playing video in a floating window when you leave the page. When off, the video stops instead." = "أبقِ الفيديو الجاري تشغيله في نافذة عائمة عند مغادرة الصفحة. عند إيقافه، سيتوقف الفيديو بدلًا من ذلك."; "Keep listening even when the window is closed" = "استمر في الاستماع حتى عند إغلاق النافذة"; "Keep Mini Player on Top" = "إبقاء المشغّل المصغّر في المقدمة"; @@ -437,6 +439,8 @@ "Sign in to YouTube Music" = "تسجيل الدخول إلى YouTube Music"; "Sign in with Google" = "تسجيل الدخول باستخدام Google"; "Sign Out" = "تسجيل الخروج"; +"Sign-In Cleanup Required" = "يلزم تنظيف تسجيل الدخول"; +"Sign Out Incomplete" = "لم يكتمل تسجيل الخروج"; "Signed in to YouTube" = "تم تسجيل الدخول إلى YouTube"; "Singles & EPs" = "الأغاني المنفردة وEPs"; "Skip Forward/Backward" = "تخطي للأمام/للخلف"; diff --git a/Sources/Kaset/Resources/de.lproj/Localizable.strings b/Sources/Kaset/Resources/de.lproj/Localizable.strings index 034d131b4..c64ca4505 100644 --- a/Sources/Kaset/Resources/de.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/de.lproj/Localizable.strings @@ -208,6 +208,8 @@ "Kaset creates fresh AI sessions per request. Refresh the status if Apple Intelligence finishes downloading or becomes available while the app is open." = "Kaset erstellt für jede Anfrage eine neue KI-Sitzung. Aktualisiere den Status, wenn Apple Intelligence fertig geladen ist oder verfügbar wird, während die App geöffnet ist."; "Kaset works without login for public browsing, search, and playback. Sign in to access personal music collections." = "Kaset funktioniert ohne Anmeldung für öffentliches Browsen, Suchen und Wiedergeben. Melde dich an, um auf persönliche Musiksammlungen zuzugreifen."; "Kaset works without login for public YouTube search, discovery, and playback. Sign in to access personal video collections." = "Kaset funktioniert ohne Anmeldung für öffentliche YouTube-Suche, Entdecken und Wiedergabe. Melde dich an, um auf persönliche Videosammlungen zuzugreifen."; +"Kaset could not safely clear saved sign-in data. Retry before signing in again." = "Kaset konnte die gespeicherten Anmeldedaten nicht sicher löschen. Versuche es erneut, bevor du dich wieder anmeldest."; +"Kaset could not remove saved sign-in data. Try signing out again before quitting." = "Kaset konnte die gespeicherten Anmeldedaten nicht entfernen. Versuche vor dem Beenden erneut, dich abzumelden."; "Keep a playing video in a floating window when you leave the page. When off, the video stops instead." = "Halte ein laufendes Video in einem schwebenden Fenster, wenn du die Seite verlässt. Ist die Option aus, stoppt das Video stattdessen."; "Keep listening even when the window is closed" = "Weiterhören, auch wenn das Fenster geschlossen ist"; "Keep Mini Player on Top" = "Mini-Player im Vordergrund halten"; @@ -437,6 +439,8 @@ "Sign in to YouTube Music" = "Bei YouTube Music anmelden"; "Sign in with Google" = "Mit Google anmelden"; "Sign Out" = "Abmelden"; +"Sign-In Cleanup Required" = "Anmeldebereinigung erforderlich"; +"Sign Out Incomplete" = "Abmeldung unvollständig"; "Signed in to YouTube" = "Bei YouTube angemeldet"; "Singles & EPs" = "Singles & EPs"; "Skip Forward/Backward" = "Vor-/Zurückspringen"; diff --git a/Sources/Kaset/Resources/en.lproj/Localizable.strings b/Sources/Kaset/Resources/en.lproj/Localizable.strings index fdf1417bd..f4f0f84b9 100644 --- a/Sources/Kaset/Resources/en.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/en.lproj/Localizable.strings @@ -8,3 +8,9 @@ "Podcast" = "Podcast"; "Episode" = "Episode"; "Chapter" = "Chapter"; + +// Sign-out durability +"Sign Out Incomplete" = "Sign Out Incomplete"; +"Kaset could not remove saved sign-in data. Try signing out again before quitting." = "Kaset could not remove saved sign-in data. Try signing out again before quitting."; +"Sign-In Cleanup Required" = "Sign-In Cleanup Required"; +"Kaset could not safely clear saved sign-in data. Retry before signing in again." = "Kaset could not safely clear saved sign-in data. Retry before signing in again."; diff --git a/Sources/Kaset/Resources/es.lproj/Localizable.strings b/Sources/Kaset/Resources/es.lproj/Localizable.strings index 2c8fa188c..d7e13d793 100644 --- a/Sources/Kaset/Resources/es.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/es.lproj/Localizable.strings @@ -208,6 +208,8 @@ "Kaset creates fresh AI sessions per request. Refresh the status if Apple Intelligence finishes downloading or becomes available while the app is open." = "Kaset crea sesiones nuevas de IA para cada solicitud. Actualiza el estado si Apple Intelligence termina de descargarse o pasa a estar disponible mientras la app está abierta."; "Kaset works without login for public browsing, search, and playback. Sign in to access personal music collections." = "Kaset funciona sin iniciar sesión para explorar, buscar y reproducir contenido público. Inicia sesión para acceder a tus colecciones personales de música."; "Kaset works without login for public YouTube search, discovery, and playback. Sign in to access personal video collections." = "Kaset funciona sin iniciar sesión para la búsqueda pública, el descubrimiento y la reproducción en YouTube. Inicia sesión para acceder a tus colecciones personales de videos."; +"Kaset could not safely clear saved sign-in data. Retry before signing in again." = "Kaset no pudo borrar de forma segura los datos de inicio de sesión guardados. Reinténtalo antes de volver a iniciar sesión."; +"Kaset could not remove saved sign-in data. Try signing out again before quitting." = "Kaset no pudo eliminar los datos de inicio de sesión guardados. Intenta cerrar sesión de nuevo antes de salir."; "Keep a playing video in a floating window when you leave the page. When off, the video stops instead." = "Mantén un video en reproducción en una ventana flotante al salir de la página. Si está desactivado, el video se detiene."; "Keep listening even when the window is closed" = "Sigue escuchando aunque la ventana esté cerrada"; "Keep Mini Player on Top" = "Mantener el mini reproductor encima"; @@ -437,6 +439,8 @@ "Sign in to YouTube Music" = "Iniciar sesión en YouTube Music"; "Sign in with Google" = "Iniciar sesión con Google"; "Sign Out" = "Cerrar sesión"; +"Sign-In Cleanup Required" = "Se requiere limpiar el inicio de sesión"; +"Sign Out Incomplete" = "Cierre de sesión incompleto"; "Signed in to YouTube" = "Sesión iniciada en YouTube"; "Singles & EPs" = "Sencillos y EPs"; "Skip Forward/Backward" = "Saltar adelante/atrás"; diff --git a/Sources/Kaset/Resources/fr.lproj/Localizable.strings b/Sources/Kaset/Resources/fr.lproj/Localizable.strings index 32423a74e..612f04e4b 100644 --- a/Sources/Kaset/Resources/fr.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/fr.lproj/Localizable.strings @@ -208,6 +208,8 @@ "Kaset creates fresh AI sessions per request. Refresh the status if Apple Intelligence finishes downloading or becomes available while the app is open." = "Kaset crée une nouvelle session d'IA pour chaque demande. Actualisez l'état si Apple Intelligence finit de se télécharger ou devient disponible pendant que l'app est ouverte."; "Kaset works without login for public browsing, search, and playback. Sign in to access personal music collections." = "Kaset fonctionne sans connexion pour la navigation publique, la recherche et la lecture. Connectez-vous pour accéder à vos collections musicales personnelles."; "Kaset works without login for public YouTube search, discovery, and playback. Sign in to access personal video collections." = "Kaset fonctionne sans connexion pour la recherche publique, la découverte et la lecture sur YouTube. Connectez-vous pour accéder à vos collections vidéo personnelles."; +"Kaset could not safely clear saved sign-in data. Retry before signing in again." = "Kaset n’a pas pu effacer en toute sécurité les données de connexion enregistrées. Réessayez avant de vous reconnecter."; +"Kaset could not remove saved sign-in data. Try signing out again before quitting." = "Kaset n’a pas pu supprimer les données de connexion enregistrées. Essayez de vous déconnecter à nouveau avant de quitter."; "Keep a playing video in a floating window when you leave the page. When off, the video stops instead." = "Garde une vidéo en cours de lecture dans une fenêtre flottante quand vous quittez la page. Sinon, la vidéo s'arrête."; "Keep listening even when the window is closed" = "Continuer l'écoute même lorsque la fenêtre est fermée"; "Keep Mini Player on Top" = "Garder le mini lecteur au premier plan"; @@ -437,6 +439,8 @@ "Sign in to YouTube Music" = "Se connecter à YouTube Music"; "Sign in with Google" = "Se connecter avec Google"; "Sign Out" = "Se déconnecter"; +"Sign-In Cleanup Required" = "Nettoyage de connexion requis"; +"Sign Out Incomplete" = "Déconnexion incomplète"; "Signed in to YouTube" = "Connecté à YouTube"; "Singles & EPs" = "Singles et EP"; "Skip Forward/Backward" = "Avancer/Reculer"; diff --git a/Sources/Kaset/Resources/id.lproj/Localizable.strings b/Sources/Kaset/Resources/id.lproj/Localizable.strings index db84628b2..62ab3d827 100644 --- a/Sources/Kaset/Resources/id.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/id.lproj/Localizable.strings @@ -208,6 +208,8 @@ "Kaset creates fresh AI sessions per request. Refresh the status if Apple Intelligence finishes downloading or becomes available while the app is open." = "Kaset membuat sesi AI baru untuk setiap permintaan. Segarkan status jika Apple Intelligence selesai diunduh atau menjadi tersedia saat app terbuka."; "Kaset works without login for public browsing, search, and playback. Sign in to access personal music collections." = "Kaset berfungsi tanpa login untuk penelusuran, pencarian, dan pemutaran publik. Masuk untuk mengakses koleksi musik pribadi."; "Kaset works without login for public YouTube search, discovery, and playback. Sign in to access personal video collections." = "Kaset berfungsi tanpa login untuk pencarian, penemuan, dan pemutaran YouTube publik. Masuk untuk mengakses koleksi video pribadi."; +"Kaset could not safely clear saved sign-in data. Retry before signing in again." = "Kaset tidak dapat menghapus data masuk yang tersimpan dengan aman. Coba lagi sebelum masuk kembali."; +"Kaset could not remove saved sign-in data. Try signing out again before quitting." = "Kaset tidak dapat menghapus data masuk yang tersimpan. Coba keluar lagi sebelum menutup aplikasi."; "Keep a playing video in a floating window when you leave the page. When off, the video stops instead." = "Pertahankan video yang sedang diputar di jendela mengambang saat Anda meninggalkan halaman. Jika nonaktif, video akan berhenti."; "Keep listening even when the window is closed" = "Lanjutkan mendengarkan meski jendela ditutup"; "Keep Mini Player on Top" = "Tampilkan Mini Player di Atas"; @@ -437,6 +439,8 @@ "Sign in to YouTube Music" = "Masuk ke YouTube Music"; "Sign in with Google" = "Masuk dengan Google"; "Sign Out" = "Keluar"; +"Sign-In Cleanup Required" = "Pembersihan proses masuk diperlukan"; +"Sign Out Incomplete" = "Proses keluar belum selesai"; "Signed in to YouTube" = "Sudah masuk ke YouTube"; "Singles & EPs" = "Single & EP"; "Skip Forward/Backward" = "Lompat Maju/Mundur"; diff --git a/Sources/Kaset/Resources/it.lproj/Localizable.strings b/Sources/Kaset/Resources/it.lproj/Localizable.strings index 1179b58e1..c8e39c87a 100644 --- a/Sources/Kaset/Resources/it.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/it.lproj/Localizable.strings @@ -208,6 +208,8 @@ "Kaset creates fresh AI sessions per request. Refresh the status if Apple Intelligence finishes downloading or becomes available while the app is open." = "Kaset crea una nuova sessione AI per ogni richiesta. Aggiorna lo stato se Apple Intelligence termina il download o diventa disponibile mentre l'app è aperta."; "Kaset works without login for public browsing, search, and playback. Sign in to access personal music collections." = "Kaset funziona senza accesso per navigazione pubblica, ricerca e riproduzione. Accedi per vedere le tue raccolte musicali personali."; "Kaset works without login for public YouTube search, discovery, and playback. Sign in to access personal video collections." = "Kaset funziona senza accesso per ricerca pubblica, scoperta e riproduzione su YouTube. Accedi per vedere le tue raccolte video personali."; +"Kaset could not safely clear saved sign-in data. Retry before signing in again." = "Kaset non ha potuto eliminare in modo sicuro i dati di accesso salvati. Riprova prima di accedere di nuovo."; +"Kaset could not remove saved sign-in data. Try signing out again before quitting." = "Kaset non ha potuto rimuovere i dati di accesso salvati. Prova a disconnetterti di nuovo prima di uscire."; "Keep a playing video in a floating window when you leave the page. When off, the video stops instead." = "Mantieni un video in riproduzione in una finestra mobile quando lasci la pagina. Se disattivato, il video si interrompe."; "Keep listening even when the window is closed" = "Continua ad ascoltare anche quando la finestra è chiusa"; "Keep Mini Player on Top" = "Mantieni mini player in primo piano"; @@ -437,6 +439,8 @@ "Sign in to YouTube Music" = "Accedi a YouTube Music"; "Sign in with Google" = "Accedi con Google"; "Sign Out" = "Esci"; +"Sign-In Cleanup Required" = "Pulizia dell’accesso necessaria"; +"Sign Out Incomplete" = "Disconnessione incompleta"; "Signed in to YouTube" = "Accesso eseguito a YouTube"; "Singles & EPs" = "Singoli ed EP"; "Skip Forward/Backward" = "Salta avanti/indietro"; diff --git a/Sources/Kaset/Resources/ko.lproj/Localizable.strings b/Sources/Kaset/Resources/ko.lproj/Localizable.strings index c11ee467b..f6c2ba792 100644 --- a/Sources/Kaset/Resources/ko.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/ko.lproj/Localizable.strings @@ -208,6 +208,8 @@ "Kaset creates fresh AI sessions per request. Refresh the status if Apple Intelligence finishes downloading or becomes available while the app is open." = "Kaset은 요청마다 새로운 AI 세션을 만듭니다. 앱이 열려 있는 동안 Apple Intelligence 다운로드가 완료되거나 사용 가능해지면 상태를 새로 고치세요."; "Kaset works without login for public browsing, search, and playback. Sign in to access personal music collections." = "Kaset은 로그인 없이 공개 탐색, 검색 및 재생을 사용할 수 있습니다. 개인 음악 모음을 보려면 로그인하세요."; "Kaset works without login for public YouTube search, discovery, and playback. Sign in to access personal video collections." = "Kaset은 로그인 없이 공개 YouTube 검색, 탐색 및 재생을 사용할 수 있습니다. 개인 동영상 모음을 보려면 로그인하세요."; +"Kaset could not safely clear saved sign-in data. Retry before signing in again." = "Kaset이 저장된 로그인 데이터를 안전하게 지우지 못했습니다. 다시 로그인하기 전에 재시도하세요."; +"Kaset could not remove saved sign-in data. Try signing out again before quitting." = "Kaset이 저장된 로그인 데이터를 제거하지 못했습니다. 앱을 종료하기 전에 다시 로그아웃해 보세요."; "Keep a playing video in a floating window when you leave the page. When off, the video stops instead." = "페이지를 벗어나도 재생 중인 비디오를 떠 있는 윈도우에 유지합니다. 끄면 대신 비디오가 중지됩니다."; "Keep listening even when the window is closed" = "창이 닫혀도 계속 재생"; "Keep Mini Player on Top" = "미니 플레이어를 항상 위에 표시"; @@ -437,6 +439,8 @@ "Sign in to YouTube Music" = "YouTube Music에 로그인"; "Sign in with Google" = "Google로 로그인"; "Sign Out" = "로그아웃"; +"Sign-In Cleanup Required" = "로그인 정리 필요"; +"Sign Out Incomplete" = "로그아웃이 완료되지 않음"; "Signed in to YouTube" = "YouTube에 로그인됨"; "Singles & EPs" = "싱글 및 EP"; "Skip Forward/Backward" = "앞으로/뒤로 건너뛰기"; diff --git a/Sources/Kaset/Resources/nl.lproj/Localizable.strings b/Sources/Kaset/Resources/nl.lproj/Localizable.strings index 1de810fcb..7c0cebdb6 100644 --- a/Sources/Kaset/Resources/nl.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/nl.lproj/Localizable.strings @@ -208,6 +208,8 @@ "Kaset creates fresh AI sessions per request. Refresh the status if Apple Intelligence finishes downloading or becomes available while the app is open." = "Kaset maakt voor elk verzoek een nieuwe AI-sessie aan. Vernieuw de status als Apple Intelligence klaar is met downloaden of beschikbaar wordt terwijl de app geopend is."; "Kaset works without login for public browsing, search, and playback. Sign in to access personal music collections." = "Kaset werkt zonder inloggen voor openbaar bladeren, zoeken en afspelen. Log in voor toegang tot persoonlijke muziekcollecties."; "Kaset works without login for public YouTube search, discovery, and playback. Sign in to access personal video collections." = "Kaset werkt zonder inloggen voor openbare YouTube-zoekopdrachten, ontdekking en weergave. Log in voor toegang tot persoonlijke videocollecties."; +"Kaset could not safely clear saved sign-in data. Retry before signing in again." = "Kaset kon de opgeslagen aanmeldgegevens niet veilig wissen. Probeer het opnieuw voordat je je weer aanmeldt."; +"Kaset could not remove saved sign-in data. Try signing out again before quitting." = "Kaset kon de opgeslagen aanmeldgegevens niet verwijderen. Probeer opnieuw af te melden voordat je stopt."; "Keep a playing video in a floating window when you leave the page. When off, the video stops instead." = "Houd een afspelende video in een zwevend venster wanneer je de pagina verlaat. Als dit uit staat, stopt de video."; "Keep listening even when the window is closed" = "Blijf luisteren, zelfs wanneer het venster is gesloten"; "Keep Mini Player on Top" = "Mini-speler bovenop houden"; @@ -437,6 +439,8 @@ "Sign in to YouTube Music" = "Log in bij YouTube Music"; "Sign in with Google" = "Log in met Google"; "Sign Out" = "Log uit"; +"Sign-In Cleanup Required" = "Aanmeldgegevens moeten worden opgeschoond"; +"Sign Out Incomplete" = "Afmelden niet voltooid"; "Signed in to YouTube" = "Ingelogd bij YouTube"; "Singles & EPs" = "Singles en EP's"; "Skip Forward/Backward" = "Vooruit/achteruit springen"; diff --git a/Sources/Kaset/Resources/pl.lproj/Localizable.strings b/Sources/Kaset/Resources/pl.lproj/Localizable.strings index a85741ad1..adfc3040b 100644 --- a/Sources/Kaset/Resources/pl.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/pl.lproj/Localizable.strings @@ -208,6 +208,8 @@ "Kaset creates fresh AI sessions per request. Refresh the status if Apple Intelligence finishes downloading or becomes available while the app is open." = "Kaset tworzy nową sesję SI dla każdego żądania. Odśwież stan, jeśli Apple Intelligence zakończy pobieranie lub stanie się dostępne, gdy aplikacja jest otwarta."; "Kaset works without login for public browsing, search, and playback. Sign in to access personal music collections." = "Kaset działa bez logowania w przypadku publicznego przeglądania, wyszukiwania i odtwarzania. Zaloguj się, aby uzyskać dostęp do osobistych kolekcji muzyki."; "Kaset works without login for public YouTube search, discovery, and playback. Sign in to access personal video collections." = "Kaset działa bez logowania w przypadku publicznego wyszukiwania, odkrywania i odtwarzania w YouTube. Zaloguj się, aby uzyskać dostęp do osobistych kolekcji wideo."; +"Kaset could not safely clear saved sign-in data. Retry before signing in again." = "Kaset nie mógł bezpiecznie usunąć zapisanych danych logowania. Spróbuj ponownie przed kolejnym logowaniem."; +"Kaset could not remove saved sign-in data. Try signing out again before quitting." = "Kaset nie mógł usunąć zapisanych danych logowania. Spróbuj wylogować się ponownie przed zamknięciem aplikacji."; "Keep a playing video in a floating window when you leave the page. When off, the video stops instead." = "Utrzymuj odtwarzany film w pływającym oknie po opuszczeniu strony. Gdy opcja jest wyłączona, film zatrzyma się."; "Keep listening even when the window is closed" = "Kontynuuj słuchanie nawet po zamknięciu okna"; "Keep Mini Player on Top" = "Trzymaj mini odtwarzacz na wierzchu"; @@ -437,6 +439,8 @@ "Sign in to YouTube Music" = "Zaloguj się do YouTube Music"; "Sign in with Google" = "Zaloguj się przez Google"; "Sign Out" = "Wyloguj"; +"Sign-In Cleanup Required" = "Wymagane oczyszczenie logowania"; +"Sign Out Incomplete" = "Wylogowanie nie zostało ukończone"; "Signed in to YouTube" = "Zalogowano do YouTube"; "Singles & EPs" = "Single i EP-ki"; "Skip Forward/Backward" = "Przewiń do przodu/do tyłu"; diff --git a/Sources/Kaset/Resources/pt.lproj/Localizable.strings b/Sources/Kaset/Resources/pt.lproj/Localizable.strings index ab9ca6e58..5b767decf 100644 --- a/Sources/Kaset/Resources/pt.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/pt.lproj/Localizable.strings @@ -208,6 +208,8 @@ "Kaset creates fresh AI sessions per request. Refresh the status if Apple Intelligence finishes downloading or becomes available while the app is open." = "O Kaset cria novas sessões de IA para cada pedido. Atualize o estado se o Apple Intelligence terminar a transferência ou ficar disponível enquanto a app estiver aberta."; "Kaset works without login for public browsing, search, and playback. Sign in to access personal music collections." = "O Kaset funciona sem iniciar sessão para exploração, pesquisa e reprodução públicas. Inicie sessão para aceder a coleções pessoais de música."; "Kaset works without login for public YouTube search, discovery, and playback. Sign in to access personal video collections." = "O Kaset funciona sem iniciar sessão para pesquisa, descoberta e reprodução públicas no YouTube. Inicie sessão para aceder a coleções pessoais de vídeos."; +"Kaset could not safely clear saved sign-in data. Retry before signing in again." = "O Kaset não conseguiu limpar em segurança os dados de início de sessão guardados. Tente novamente antes de iniciar sessão outra vez."; +"Kaset could not remove saved sign-in data. Try signing out again before quitting." = "O Kaset não conseguiu remover os dados de início de sessão guardados. Tente terminar sessão novamente antes de sair."; "Keep a playing video in a floating window when you leave the page. When off, the video stops instead." = "Mantenha um vídeo em reprodução numa janela flutuante quando sair da página. Se estiver desativado, o vídeo para em vez disso."; "Keep listening even when the window is closed" = "Continue a ouvir mesmo quando a janela estiver fechada"; "Keep Mini Player on Top" = "Manter o mini player no topo"; @@ -437,6 +439,8 @@ "Sign in to YouTube Music" = "Iniciar sessão no YouTube Music"; "Sign in with Google" = "Iniciar sessão com o Google"; "Sign Out" = "Terminar sessão"; +"Sign-In Cleanup Required" = "É necessário limpar o início de sessão"; +"Sign Out Incomplete" = "Fim de sessão incompleto"; "Signed in to YouTube" = "Sessão iniciada no YouTube"; "Singles & EPs" = "Singles e EPs"; "Skip Forward/Backward" = "Avançar/recuar"; diff --git a/Sources/Kaset/Resources/ru.lproj/Localizable.strings b/Sources/Kaset/Resources/ru.lproj/Localizable.strings index 6f222d74b..edad6036a 100644 --- a/Sources/Kaset/Resources/ru.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/ru.lproj/Localizable.strings @@ -208,6 +208,8 @@ "Kaset creates fresh AI sessions per request. Refresh the status if Apple Intelligence finishes downloading or becomes available while the app is open." = "Kaset создаёт новую сессию ИИ для каждого запроса. Обновите статус, если Apple Intelligence завершит загрузку или станет доступен, пока приложение открыто."; "Kaset works without login for public browsing, search, and playback. Sign in to access personal music collections." = "Kaset работает без входа для публичного просмотра, поиска и воспроизведения. Войдите, чтобы получить доступ к личным музыкальным коллекциям."; "Kaset works without login for public YouTube search, discovery, and playback. Sign in to access personal video collections." = "Kaset работает без входа для публичного поиска, рекомендаций и воспроизведения на YouTube. Войдите, чтобы получить доступ к личным коллекциям видео."; +"Kaset could not safely clear saved sign-in data. Retry before signing in again." = "Kaset не удалось безопасно удалить сохранённые данные для входа. Повторите попытку перед новым входом."; +"Kaset could not remove saved sign-in data. Try signing out again before quitting." = "Kaset не удалось удалить сохранённые данные для входа. Попробуйте выйти ещё раз перед завершением работы."; "Keep a playing video in a floating window when you leave the page. When off, the video stops instead." = "Оставляет воспроизводящееся видео в плавающем окне, когда вы покидаете страницу. Если выключено, видео останавливается."; "Keep listening even when the window is closed" = "Продолжайте слушать, даже когда окно закрыто"; "Keep Mini Player on Top" = "Держать мини-плеер поверх окон"; @@ -437,6 +439,8 @@ "Sign in to YouTube Music" = "Войти в YouTube Music"; "Sign in with Google" = "Войти через Google"; "Sign Out" = "Выйти"; +"Sign-In Cleanup Required" = "Требуется очистка данных входа"; +"Sign Out Incomplete" = "Выход не завершён"; "Signed in to YouTube" = "Вход в YouTube выполнен"; "Singles & EPs" = "Синглы и EP"; "Skip Forward/Backward" = "Перемотка вперед/назад"; diff --git a/Sources/Kaset/Resources/sv.lproj/Localizable.strings b/Sources/Kaset/Resources/sv.lproj/Localizable.strings index 88253dd98..1b39f897c 100644 --- a/Sources/Kaset/Resources/sv.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/sv.lproj/Localizable.strings @@ -208,6 +208,8 @@ "Kaset creates fresh AI sessions per request. Refresh the status if Apple Intelligence finishes downloading or becomes available while the app is open." = "Kaset skapar nya AI-sessioner för varje förfrågan. Uppdatera statusen om Apple Intelligence laddas klart eller blir tillgängligt medan appen är öppen."; "Kaset works without login for public browsing, search, and playback. Sign in to access personal music collections." = "Kaset fungerar utan inloggning för offentlig bläddring, sökning och uppspelning. Logga in för att komma åt personliga musiksamlingar."; "Kaset works without login for public YouTube search, discovery, and playback. Sign in to access personal video collections." = "Kaset fungerar utan inloggning för offentlig YouTube-sökning, upptäckt och uppspelning. Logga in för att komma åt personliga videosamlingar."; +"Kaset could not safely clear saved sign-in data. Retry before signing in again." = "Kaset kunde inte rensa sparade inloggningsdata på ett säkert sätt. Försök igen innan du loggar in på nytt."; +"Kaset could not remove saved sign-in data. Try signing out again before quitting." = "Kaset kunde inte ta bort sparade inloggningsdata. Försök logga ut igen innan du avslutar."; "Keep a playing video in a floating window when you leave the page. When off, the video stops instead." = "Behåll en spelande video i ett flytande fönster när du lämnar sidan. När det är av stannar videon i stället."; "Keep listening even when the window is closed" = "Fortsätt lyssna även när fönstret är stängt"; "Keep Mini Player on Top" = "Håll minispelaren överst"; @@ -437,6 +439,8 @@ "Sign in to YouTube Music" = "Logga in i YouTube Music"; "Sign in with Google" = "Logga in med Google"; "Sign Out" = "Logga ut"; +"Sign-In Cleanup Required" = "Inloggningsdata måste rensas"; +"Sign Out Incomplete" = "Utloggningen slutfördes inte"; "Signed in to YouTube" = "Inloggad på YouTube"; "Singles & EPs" = "Singlar och EP"; "Skip Forward/Backward" = "Hoppa framåt/bakåt"; diff --git a/Sources/Kaset/Resources/tr.lproj/Localizable.strings b/Sources/Kaset/Resources/tr.lproj/Localizable.strings index 3ff0783cb..f94647d52 100644 --- a/Sources/Kaset/Resources/tr.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/tr.lproj/Localizable.strings @@ -208,6 +208,8 @@ "Kaset creates fresh AI sessions per request. Refresh the status if Apple Intelligence finishes downloading or becomes available while the app is open." = "Kaset her istek için yeni bir yapay zeka oturumu oluşturur. Uygulama açıkken Apple Intelligence indirmesi tamamlanırsa veya kullanılabilir hale gelirse durumu yenileyin."; "Kaset works without login for public browsing, search, and playback. Sign in to access personal music collections." = "Kaset, herkese açık gezinme, arama ve oynatma için giriş yapmadan çalışır. Kişisel müzik koleksiyonlarına erişmek için giriş yapın."; "Kaset works without login for public YouTube search, discovery, and playback. Sign in to access personal video collections." = "Kaset, herkese açık YouTube araması, keşif ve oynatma için giriş yapmadan çalışır. Kişisel video koleksiyonlarına erişmek için giriş yapın."; +"Kaset could not safely clear saved sign-in data. Retry before signing in again." = "Kaset kayıtlı oturum açma verilerini güvenli bir şekilde temizleyemedi. Yeniden oturum açmadan önce tekrar deneyin."; +"Kaset could not remove saved sign-in data. Try signing out again before quitting." = "Kaset kayıtlı oturum açma verilerini kaldıramadı. Uygulamadan çıkmadan önce yeniden çıkış yapmayı deneyin."; "Keep a playing video in a floating window when you leave the page. When off, the video stops instead." = "Sayfadan ayrıldığınızda oynatılan videoyu kayan bir pencerede tutar. Kapalıysa video bunun yerine durur."; "Keep listening even when the window is closed" = "Pencere kapalıyken bile dinlemeye devam edin"; "Keep Mini Player on Top" = "Mini Oynatıcıyı Üstte Tut"; @@ -437,6 +439,8 @@ "Sign in to YouTube Music" = "YouTube Music'te oturum aç"; "Sign in with Google" = "Google ile giriş yap"; "Sign Out" = "Çıkış Yap"; +"Sign-In Cleanup Required" = "Oturum Açma Temizliği Gerekli"; +"Sign Out Incomplete" = "Çıkış Tamamlanmadı"; "Signed in to YouTube" = "YouTube'da oturum açık"; "Singles & EPs" = "Single'lar ve EP'ler"; "Skip Forward/Backward" = "İleri/Geri Atla"; diff --git a/Sources/Kaset/Resources/uk.lproj/Localizable.strings b/Sources/Kaset/Resources/uk.lproj/Localizable.strings index e72707d7f..55a721a50 100644 --- a/Sources/Kaset/Resources/uk.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/uk.lproj/Localizable.strings @@ -208,6 +208,8 @@ "Kaset creates fresh AI sessions per request. Refresh the status if Apple Intelligence finishes downloading or becomes available while the app is open." = "Kaset створює нові сесії ШІ для кожного запиту. Оновіть стан, якщо Apple Intelligence завершить завантаження або стане доступним, поки застосунок відкрито."; "Kaset works without login for public browsing, search, and playback. Sign in to access personal music collections." = "Kaset працює без входу для публічного перегляду, пошуку й відтворення. Увійдіть, щоб отримати доступ до особистих музичних колекцій."; "Kaset works without login for public YouTube search, discovery, and playback. Sign in to access personal video collections." = "Kaset працює без входу для публічного пошуку, рекомендацій і відтворення на YouTube. Увійдіть, щоб отримати доступ до особистих колекцій відео."; +"Kaset could not safely clear saved sign-in data. Retry before signing in again." = "Kaset не вдалося безпечно видалити збережені дані входу. Повторіть спробу перед новим входом."; +"Kaset could not remove saved sign-in data. Try signing out again before quitting." = "Kaset не вдалося видалити збережені дані входу. Спробуйте вийти ще раз перед завершенням роботи."; "Keep a playing video in a floating window when you leave the page. When off, the video stops instead." = "Залишає відео, що відтворюється, у плаваючому вікні, коли ви залишаєте сторінку. Якщо вимкнено, відео зупиняється."; "Keep listening even when the window is closed" = "Продовжуйте слухати, навіть коли вікно закрито"; "Keep Mini Player on Top" = "Тримати міні-плеєр поверх інших вікон"; @@ -437,6 +439,8 @@ "Sign in to YouTube Music" = "Увійти в YouTube Music"; "Sign in with Google" = "Увійти через Google"; "Sign Out" = "Вийти"; +"Sign-In Cleanup Required" = "Потрібне очищення даних входу"; +"Sign Out Incomplete" = "Вихід не завершено"; "Signed in to YouTube" = "Увійшли в YouTube"; "Singles & EPs" = "Сингли та EP"; "Skip Forward/Backward" = "Перемотати вперед/назад"; diff --git a/Sources/Kaset/Services/Auth/AuthService.swift b/Sources/Kaset/Services/Auth/AuthService.swift index c253f57f9..56732d2a0 100644 --- a/Sources/Kaset/Services/Auth/AuthService.swift +++ b/Sources/Kaset/Services/Auth/AuthService.swift @@ -31,6 +31,18 @@ final class AuthService: AuthServiceProtocol { /// Flag indicating whether re-authentication is needed. var needsReauth: Bool = false + /// Whether a failed login cleanup must be retried before another sign-in. + private(set) var loginCleanupRequired = false + + /// Whether failed-login cleanup still owns the account boundary. + private(set) var isLoginCleanupInProgress = false + + /// Whether pending cleanup originated from a guest/logged-out login attempt. + private var loginCleanupPersistsGuestPlaybackState = false + + /// Changes synchronously whenever an explicit sign-out begins. + private(set) var signOutSequence: UInt64 = 0 + /// Changes whenever the authenticated Google-user identity must be treated as unverified. private(set) var accountIdentityGeneration: UInt64 = 0 @@ -47,6 +59,9 @@ final class AuthService: AuthServiceProtocol { /// Reauth prompts keep the existing account-cookie playback store so active /// playback is not torn down while the user re-authenticates. var shouldUseCookieFreePlaybackDataStore: Bool { + if self.loginCleanupRequired { + return true + } if self.isGuestModeEnabled { return true } @@ -63,6 +78,9 @@ final class AuthService: AuthServiceProtocol { /// A signed-out user can temporarily be `.loggingIn` while the login sheet is /// open; that flow should still preserve guest-owned queues if cancelled. var shouldPersistGuestPlaybackState: Bool { + if self.loginCleanupRequired { + return self.loginCleanupPersistsGuestPlaybackState + } guard !self.needsReauth else { return false } if self.isGuestModeEnabled { return true @@ -81,12 +99,16 @@ final class AuthService: AuthServiceProtocol { private var stateBeforeLogin: State? private var loginCheckTask: Task? private var loginCheckGeneration: UInt64 = 0 - private var signOutTask: Task? + private var nextLoginAttemptID: UInt64 = 0 + private(set) var activeLoginAttemptID: LoginAttemptID? + private var cancellingLoginAttemptID: LoginAttemptID? + private var signOutTask: Task? private var signOutPreparation: (@MainActor @Sendable () async -> Void)? private var accountBoundaryWillBegin: (@MainActor @Sendable () -> Void)? private var accountBoundaryDidEnd: (@MainActor @Sendable () -> Void)? private var accountBoundaryDrain: (@MainActor @Sendable () async -> Void)? private var guestModeTransitionGeneration: UInt64 = 0 + private var loginCleanupOperationGeneration: UInt64 = 0 init(webKitManager: WebKitManagerProtocol = WebKitManager.shared) { self.webKitManager = webKitManager @@ -107,6 +129,13 @@ final class AuthService: AuthServiceProtocol { } } + func setLoginCleanupRequired(_ required: Bool) { + self.updateLoginCleanupRequirement( + required, + requiresReauthentication: self.loginFailureRequiresReauthentication + ) + } + /// Temporarily uses public guest mode while preserving the signed-in session. func enterGuestMode() async { guard self.state.isLoggedIn else { return } @@ -164,20 +193,53 @@ final class AuthService: AuthServiceProtocol { self.logger.info("Ignoring login request while sign-out is in progress") return } - self.invalidateLoginCheck() - if self.state != .loggingIn { - self.stateBeforeLogin = self.state + guard !self.loginCleanupRequired, !self.isLoginCleanupInProgress else { + self.logger.info("Ignoring login request while failed-login cleanup is pending") + return + } + guard self.state != .loggingIn, self.activeLoginAttemptID == nil else { + self.logger.info("Ignoring login request while an attempt is active") + return } + self.invalidateLoginCheck() + self.stateBeforeLogin = self.state + self.nextLoginAttemptID &+= 1 + self.activeLoginAttemptID = LoginAttemptID(rawValue: self.nextLoginAttemptID) + self.cancellingLoginAttemptID = nil self.state = .loggingIn } /// Cancels an in-progress login presentation without changing an already /// completed authenticated session. - func cancelLoginIfNeeded() { - guard self.state == .loggingIn else { return } - self.logger.info("Login flow cancelled") + func cancelLoginIfNeeded(expectedAttemptID: LoginAttemptID? = nil) { + guard self.beginLoginCancellation(expectedAttemptID: expectedAttemptID) else { return } + self.finishLoginCancellation() + } + + func beginLoginCancellation(expectedAttemptID: LoginAttemptID?) -> Bool { + guard self.state == .loggingIn else { return false } + if let expectedAttemptID, + self.activeLoginAttemptID != expectedAttemptID + { + self.logger.info("Ignoring cancellation from a stale login attempt") + return false + } + self.logger.info("Login flow cancellation started") + self.invalidateLoginCheck() + self.cancellingLoginAttemptID = self.activeLoginAttemptID + return true + } + + func finishLoginCancellation() { + guard self.state == .loggingIn, + let cancellingLoginAttemptID = self.cancellingLoginAttemptID, + self.activeLoginAttemptID == cancellingLoginAttemptID + else { return } + self.activeLoginAttemptID = nil + self.cancellingLoginAttemptID = nil self.state = self.stateBeforeLogin ?? .loggedOut self.stateBeforeLogin = nil + self.logger.info("Login flow cancelled") } /// Registers account-owned WebKit mutation cleanup that every sign-out must await. @@ -201,7 +263,7 @@ final class AuthService: AuthServiceProtocol { /// Waits for the initial Keychain restore before reading WebKit cookies. func checkLoginStatus() async { if let signOutTask = self.signOutTask { - await signOutTask.value + _ = await signOutTask.value if self.signOutTask == signOutTask { self.signOutTask = nil } @@ -211,6 +273,14 @@ final class AuthService: AuthServiceProtocol { await loginCheckTask.value return } + guard self.activeLoginAttemptID == nil, self.state != .loggingIn else { + self.logger.info("Ignoring login-status check while a login attempt is active") + return + } + guard !self.loginCleanupRequired else { + self.logger.error("Refusing to evaluate authentication until cookie cleanup succeeds") + return + } self.loginCheckGeneration &+= 1 let checkGeneration = self.loginCheckGeneration @@ -227,6 +297,7 @@ final class AuthService: AuthServiceProtocol { ) else { return } if resolvedState.isLoggedIn { self.needsReauth = false + self.updateLoginCleanupRequirement(false, requiresReauthentication: false) } } self.loginCheckTask = task @@ -240,18 +311,7 @@ final class AuthService: AuthServiceProtocol { self.cancelPendingGuestModeTransition() self.accountBoundaryWillBegin?() defer { self.accountBoundaryDidEnd?() } - self.logger.warning("Session expired, requiring re-authentication") - self.invalidateLoginCheck() - self.advanceAccountIdentityGeneration() - self.needsReauth = true - self.isGuestModeEnabled = false - SongLikeStatusManager.shared.clearCache() - self.state = .loggedOut - self.stateBeforeLogin = nil - // Drop cached personalized responses so a later login in the same - // session can't be served the previous user's data (incl. the - // account-unknown "pending" cache scope) before its TTL expires. - self.clearAPIResponseCaches() + self.applySessionExpiration() } /// Expires only the authentication identity that originated an async request. @@ -261,17 +321,24 @@ final class AuthService: AuthServiceProtocol { } /// Signs out the user by draining account-owned WebKit mutations, then clearing all data. - func signOut() async { + @discardableResult + func signOut() async -> Bool { if let signOutTask = self.signOutTask { - await signOutTask.value - return + return await signOutTask.value } self.logger.info("Signing out user") + guard self.webKitManager.invalidateAuthCookieRestoration() else { + self.logger.error("Could not persist sign-out intent before account drain") + return false + } + self.signOutSequence &+= 1 self.cancelPendingGuestModeTransition() self.accountBoundaryWillBegin?() // Fence authenticated work synchronously before the first suspension. self.invalidateLoginCheck() + self.activeLoginAttemptID = nil + self.cancellingLoginAttemptID = nil self.advanceAccountIdentityGeneration() self.clearAPIResponseCaches() self.state = .loggedOut @@ -281,19 +348,29 @@ final class AuthService: AuthServiceProtocol { let preparation = self.signOutPreparation let task = Task { @MainActor [weak self] in - guard let self else { return } + guard let self else { return false } defer { self.accountBoundaryDidEnd?() } await self.accountBoundaryDrain?() await preparation?() - await self.webKitManager.clearAllData() + let didInvalidatePersistedCookies = await self.webKitManager.clearAllData() self.clearAPIResponseCaches() - self.logger.info("User signed out successfully") + self.updateLoginCleanupRequirement( + !didInvalidatePersistedCookies, + requiresReauthentication: false + ) + if didInvalidatePersistedCookies { + self.logger.info("User signed out successfully") + } else { + self.logger.error("User signed out, but durable cookie invalidation failed") + } + return didInvalidatePersistedCookies } self.signOutTask = task - await task.value + let didSignOutDurably = await task.value if self.signOutTask == task { self.signOutTask = nil } + return didSignOutDurably } private func clearAPIResponseCaches() { @@ -301,26 +378,215 @@ final class AuthService: AuthServiceProtocol { URLCache.shared.removeAllCachedResponses() } - /// Called when login completes successfully (from LoginSheet observation). - func completeLoginAfterDraining(sapisid: String) async { - self.logger.info("Login completed successfully") - guard self.signOutTask == nil else { - self.logger.info("Ignoring login completion while sign-out is in progress") - return + /// Commits a detected login only if its attempt remains current while account work drains. + func completeLoginAfterDraining( + expectedAttemptID: LoginAttemptID, + persistBeforeCommit: @escaping @MainActor @Sendable () async -> String?, + persistFinalSession: @escaping @MainActor @Sendable () async -> String?, + willPublishLogin: @escaping @MainActor @Sendable () -> Void + ) async -> Bool { + guard self.signOutTask == nil, + self.state == .loggingIn, + self.activeLoginAttemptID == expectedAttemptID, + self.cancellingLoginAttemptID != expectedAttemptID + else { + self.logger.info("Ignoring login completion without the matching active attempt") + return false } self.cancelPendingGuestModeTransition() self.accountBoundaryWillBegin?() defer { self.accountBoundaryDidEnd?() } self.invalidateLoginCheck() - let completionGeneration = self.loginCheckGeneration await self.accountBoundaryDrain?() - guard self.signOutTask == nil, - completionGeneration == self.loginCheckGeneration + guard !Task.isCancelled, + self.signOutTask == nil, + self.state == .loggingIn, + self.activeLoginAttemptID == expectedAttemptID, + self.cancellingLoginAttemptID != expectedAttemptID else { self.logger.info("Ignoring login completion superseded while draining account work") - return + return false + } + guard await persistBeforeCommit() != nil else { + self.logger.info("Ignoring login completion because durable persistence did not commit") + return false + } + guard !Task.isCancelled, + self.signOutTask == nil, + self.state == .loggingIn, + self.activeLoginAttemptID == expectedAttemptID, + self.cancellingLoginAttemptID != expectedAttemptID, + let finalSessionValue = await persistFinalSession(), + !Task.isCancelled, + self.signOutTask == nil, + self.state == .loggingIn, + self.activeLoginAttemptID == expectedAttemptID, + self.cancellingLoginAttemptID != expectedAttemptID + else { + self.logger.info("Ignoring login completion superseded during final persistence") + return false + } + willPublishLogin() + self.applyCompletedLogin(sapisid: finalSessionValue) + self.activeLoginAttemptID = nil + self.cancellingLoginAttemptID = nil + self.logger.info("Login completed successfully") + return true + } + + private var loginFailureRequiresReauthentication: Bool { + if self.needsReauth || self.state.isLoggedIn { + return true + } + if self.state == .loggingIn, self.stateBeforeLogin?.isLoggedIn == true { + return true + } + return false + } + + func resolveLoginRollbackAfterDraining( + expectedAttemptID: LoginAttemptID?, + prepareRollback: @escaping @MainActor @Sendable () async -> Bool, + rollback: @escaping @MainActor @Sendable (_ forceCleanup: Bool) async -> CookieBackupRollbackResult + ) async -> CookieBackupRollbackResult { + let requiresReauthentication = self.loginFailureRequiresReauthentication + let ownsAttempt = expectedAttemptID != nil + && self.activeLoginAttemptID == expectedAttemptID + && self.state == .loggingIn + if ownsAttempt { + _ = self.beginLoginCancellation(expectedAttemptID: expectedAttemptID) + } + + self.accountBoundaryWillBegin?() + defer { self.accountBoundaryDidEnd?() } + let didPrepareRollback = await prepareRollback() + await self.accountBoundaryDrain?() + if !didPrepareRollback { + self.logger.error("Could not disable cookie restoration before login rollback; forcing cleanup") + } + let result = await rollback(!didPrepareRollback) + + guard ownsAttempt, + self.activeLoginAttemptID == expectedAttemptID + else { + if result == .failed, self.state == .loggedOut { + self.updateLoginCleanupRequirement( + true, + requiresReauthentication: requiresReauthentication + ) + } + return result + } + + switch result { + case .rolledBack: + self.updateLoginCleanupRequirement(false, requiresReauthentication: false) + self.finishLoginCancellation() + case .superseded: + self.updateLoginCleanupRequirement( + true, + requiresReauthentication: requiresReauthentication + ) + self.applySessionExpiration() + self.needsReauth = requiresReauthentication + case .cleared: + self.updateLoginCleanupRequirement(false, requiresReauthentication: false) + self.applySessionExpiration() + self.needsReauth = requiresReauthentication + case .failed: + self.updateLoginCleanupRequirement( + true, + requiresReauthentication: requiresReauthentication + ) + self.applySessionExpiration() + self.needsReauth = requiresReauthentication } - self.applyCompletedLogin(sapisid: sapisid) + return result + } + + func clearFailedLoginAfterDraining( + expectedAttemptID: LoginAttemptID, + expectedSignOutSequence: UInt64, + clearCookies: @escaping @MainActor @Sendable () async -> Bool + ) async -> Bool? { + guard self.signOutSequence == expectedSignOutSequence else { return nil } + let requiresReauthentication = self.loginFailureRequiresReauthentication + let ownsActiveAttempt = self.activeLoginAttemptID == expectedAttemptID + let ownsLoggedOutResidual = self.activeLoginAttemptID == nil + && expectedAttemptID.rawValue == self.nextLoginAttemptID + && self.state == .loggedOut + && self.signOutTask == nil + guard ownsActiveAttempt || ownsLoggedOutResidual else { return nil } + guard self.webKitManager.invalidateAuthCookieRestoration() else { + self.updateLoginCleanupRequirement( + true, + requiresReauthentication: requiresReauthentication + ) + self.logger.error("Could not persist failed-login cleanup intent before account drain") + return false + } + + self.loginCleanupOperationGeneration &+= 1 + let cleanupOperationGeneration = self.loginCleanupOperationGeneration + self.isLoginCleanupInProgress = true + defer { + if self.loginCleanupOperationGeneration == cleanupOperationGeneration { + self.isLoginCleanupInProgress = false + } + } + + self.cancelPendingGuestModeTransition() + self.accountBoundaryWillBegin?() + defer { self.accountBoundaryDidEnd?() } + self.updateLoginCleanupRequirement( + true, + requiresReauthentication: requiresReauthentication + ) + self.applySessionExpiration() + self.needsReauth = requiresReauthentication + let cleanupGeneration = self.accountIdentityGeneration + + await self.accountBoundaryDrain?() + guard cleanupGeneration == self.accountIdentityGeneration, + self.signOutSequence == expectedSignOutSequence, + self.state == .loggedOut + else { return nil } + + let didClear = await clearCookies() + guard cleanupGeneration == self.accountIdentityGeneration, + self.signOutSequence == expectedSignOutSequence, + self.state == .loggedOut + else { return nil } + self.updateLoginCleanupRequirement( + !didClear, + requiresReauthentication: requiresReauthentication + ) + return didClear + } + + private func updateLoginCleanupRequirement( + _ required: Bool, + requiresReauthentication: Bool + ) { + self.loginCleanupRequired = required + self.loginCleanupPersistsGuestPlaybackState = required && !requiresReauthentication + } + + private func applySessionExpiration() { + self.logger.warning("Session expired, requiring re-authentication") + self.invalidateLoginCheck() + self.activeLoginAttemptID = nil + self.cancellingLoginAttemptID = nil + self.advanceAccountIdentityGeneration() + self.needsReauth = true + self.isGuestModeEnabled = false + SongLikeStatusManager.shared.clearCache() + self.state = .loggedOut + self.stateBeforeLogin = nil + // Drop cached personalized responses so a later login in the same + // session can't be served the previous user's data (incl. the + // account-unknown "pending" cache scope) before its TTL expires. + self.clearAPIResponseCaches() } #if DEBUG @@ -330,7 +596,7 @@ final class AuthService: AuthServiceProtocol { self.accountBoundaryWillBegin == nil && self.accountBoundaryDidEnd == nil && self.accountBoundaryDrain == nil, - "Use completeLoginAfterDraining(sapisid:) when account-boundary handlers are installed" + "Use completeLoginAfterDraining(expectedAttemptID:persistBeforeCommit:persistFinalSession:willPublishLogin:) when account-boundary handlers are installed" ) self.logger.info("Login completed successfully") guard self.signOutTask == nil else { @@ -340,6 +606,8 @@ final class AuthService: AuthServiceProtocol { self.cancelPendingGuestModeTransition() self.invalidateLoginCheck() self.applyCompletedLogin(sapisid: sapisid) + self.activeLoginAttemptID = nil + self.cancellingLoginAttemptID = nil } #endif @@ -354,6 +622,7 @@ final class AuthService: AuthServiceProtocol { self.clearAPIResponseCaches() self.state = .loggedIn(sapisid: sapisid) self.needsReauth = false + self.updateLoginCleanupRequirement(false, requiresReauthentication: false) self.stateBeforeLogin = nil } @@ -385,6 +654,8 @@ final class AuthService: AuthServiceProtocol { await self.accountBoundaryDrain?() } guard expectedLoginCheckGeneration == self.loginCheckGeneration, + self.activeLoginAttemptID == nil, + self.state != .loggingIn, !Task.isCancelled else { return false } if identityChanged { @@ -392,6 +663,8 @@ final class AuthService: AuthServiceProtocol { self.clearAPIResponseCaches() } self.state = newState + self.activeLoginAttemptID = nil + self.cancellingLoginAttemptID = nil return true } @@ -426,9 +699,21 @@ final class AuthService: AuthServiceProtocol { return .loggedIn(sapisid: "mock-sapisid-for-ui-tests") } + guard !self.loginCleanupRequired else { + self.logger.error("Cookie cleanup is pending; resolving authentication as logged out") + return .loggedOut + } + self.logger.debug("Checking login status from cookies") - await self.webKitManager.waitForInitialCookieRestore() + let canEvaluateAuthentication = await self.webKitManager.waitForInitialCookieRestore() guard !Task.isCancelled else { return self.state } + guard !self.loginCleanupRequired else { return .loggedOut } + guard canEvaluateAuthentication else { + self.logger.error("Initial cookie cleanup failed; refusing to evaluate authentication cookies") + self.updateLoginCleanupRequirement(true, requiresReauthentication: true) + self.needsReauth = true + return .loggedOut + } self.logger.debug("Initial cookie restore completed, checking auth cookies") #if DEBUG diff --git a/Sources/Kaset/Services/Protocols.swift b/Sources/Kaset/Services/Protocols.swift index a3218a8e1..2e8bedb6d 100644 --- a/Sources/Kaset/Services/Protocols.swift +++ b/Sources/Kaset/Services/Protocols.swift @@ -21,17 +21,69 @@ protocol WebKitManagerProtocol: AnyObject, Sendable { /// Checks if the required authentication cookies exist. func hasAuthCookies() async -> Bool + /// Synchronously persists sign-out intent before asynchronous draining begins. + @discardableResult + func invalidateAuthCookieRestoration() -> Bool + /// Clears only authentication cookies from WebKit and persisted storage. - func clearAuthCookies() async + /// Returns whether the persisted backup was durably invalidated. + @discardableResult + func clearAuthCookies() async -> Bool /// Clears all website data (cookies, cache, etc.). - func clearAllData() async + /// Returns whether the persisted backup was durably invalidated. + @discardableResult + func clearAllData() async -> Bool /// Forces an immediate backup of all YouTube/Google cookies. - func forceBackupCookies() async + /// Returns whether the latest snapshot is durably available. + func forceBackupCookies() async -> Bool + + /// Whether the latest transaction setup failed to restore its prior durable state. + var loginCookieBackupSetupRequiresCleanup: Bool { get } + + /// Starts a login-cookie transaction before the login WebView can mutate + /// authentication cookies, preserving the previous restorable archive. + func beginLoginCookieBackup() async -> CookieBackupTransaction? + + /// Persists a fresh stable snapshot for the active login transaction while + /// keeping startup restoration disabled. + func refreshLoginCookieBackup(_ transaction: CookieBackupTransaction) async -> Bool + + /// Whether this manager and its archive queue still own the transaction. + func isLoginCookieBackupActive(_ transaction: CookieBackupTransaction) async -> Bool + + /// Whether the current stable login-cookie snapshot differs from the + /// transaction's pre-login baseline. + func hasLoginCookieSnapshotChanged(_ transaction: CookieBackupTransaction) async -> Bool + + /// Makes a prepared login-cookie backup eligible for startup restoration. + @discardableResult + func commitLoginCookieBackup( + _ transaction: CookieBackupTransaction + ) async -> String? + + /// Finishes a committed login transaction after AuthService publishes the + /// matching authenticated identity. + @discardableResult + func finalizeLoginCookieBackup( + _ transaction: CookieBackupTransaction + ) async -> String? + + /// Disables restoration for a transaction before account work drains. + func prepareLoginCookieBackupRollback( + _ transaction: CookieBackupTransaction + ) async -> Bool + + /// Restores the prior archive and restoration policy after a cancelled or + /// superseded login attempt. + func rollbackLoginCookieBackup( + _ transaction: CookieBackupTransaction + ) async -> CookieBackupRollbackResult /// Waits for the startup Keychain-to-WebKit cookie restore to finish. - func waitForInitialCookieRestore() async + /// Returns whether authentication cookies are safe to evaluate afterward. + func waitForInitialCookieRestore() async -> Bool /// Logs all authentication-related cookies for debugging. func logAuthCookies() async @@ -307,6 +359,12 @@ extension YTMusicClientProtocol { } } +// MARK: - LoginAttemptID + +struct LoginAttemptID: Equatable, Sendable { + let rawValue: UInt64 +} + // MARK: - AuthServiceProtocol /// Protocol defining the interface for authentication operations. @@ -319,6 +377,21 @@ protocol AuthServiceProtocol: AnyObject, Sendable { /// Flag indicating whether re-authentication is needed. var needsReauth: Bool { get set } + /// Local identity of the currently presented login attempt. + var activeLoginAttemptID: LoginAttemptID? { get } + + /// Whether a failed login cleanup must be retried before another sign-in. + var loginCleanupRequired: Bool { get } + + /// Whether failed-login cleanup still owns the account boundary. + var isLoginCleanupInProgress: Bool { get } + + /// Changes synchronously whenever an explicit sign-out begins. + var signOutSequence: UInt64 { get } + + /// Records whether the failed-login cleanup still needs a retry. + func setLoginCleanupRequired(_ required: Bool) + /// Starts the login flow by presenting the login sheet. func startLogin() @@ -329,10 +402,32 @@ protocol AuthServiceProtocol: AnyObject, Sendable { func sessionExpired() /// Signs out the user by clearing all cookies and data. - func signOut() async - - /// Called when login completes successfully and account-scoped work has drained. - func completeLoginAfterDraining(sapisid: String) async + @discardableResult + func signOut() async -> Bool + + /// Commits a login after account-scoped work has drained. + /// Returns false when cancellation, sign-out, or a newer login attempt supersedes it. + func completeLoginAfterDraining( + expectedAttemptID: LoginAttemptID, + persistBeforeCommit: @escaping @MainActor @Sendable () async -> String?, + persistFinalSession: @escaping @MainActor @Sendable () async -> String?, + willPublishLogin: @escaping @MainActor @Sendable () -> Void + ) async -> Bool + + /// Resolves a login-cookie rollback while the account mutation boundary is held. + func resolveLoginRollbackAfterDraining( + expectedAttemptID: LoginAttemptID?, + prepareRollback: @escaping @MainActor @Sendable () async -> Bool, + rollback: @escaping @MainActor @Sendable (_ forceCleanup: Bool) async -> CookieBackupRollbackResult + ) async -> CookieBackupRollbackResult + + /// Fences and drains a published or pending login before cookie cleanup, + /// then applies the cleanup result only while that boundary remains current. + func clearFailedLoginAfterDraining( + expectedAttemptID: LoginAttemptID, + expectedSignOutSequence: UInt64, + clearCookies: @escaping @MainActor @Sendable () async -> Bool + ) async -> Bool? } // MARK: - PlayerServiceProtocol diff --git a/Sources/Kaset/Services/WebKit/AuthCookieClearCoordinator.swift b/Sources/Kaset/Services/WebKit/AuthCookieClearCoordinator.swift new file mode 100644 index 000000000..a180b14f8 --- /dev/null +++ b/Sources/Kaset/Services/WebKit/AuthCookieClearCoordinator.swift @@ -0,0 +1,99 @@ +import Foundation + +@MainActor +final class AuthCookieClearCoordinator { + enum Scope: Equatable, Sendable { + case authenticationCookies + case allWebsiteData + } + + private struct ActiveClear { + let id: UInt64 + let scope: Scope + let task: Task + } + + private struct PendingAllDataClear { + let id: UInt64 + let task: Task + } + + private var nextID: UInt64 = 0 + private var activeClear: ActiveClear? + private var pendingAllDataClear: PendingAllDataClear? + + var isBusy: Bool { + self.activeClear != nil || self.pendingAllDataClear != nil + } + + func run( + scope: Scope, + operation: @escaping @MainActor @Sendable () async -> Bool + ) async -> Bool { + if let pendingAllDataClear { + let result = await pendingAllDataClear.task.value + self.finishPendingAllDataClear(id: pendingAllDataClear.id) + return result + } + + if let activeClear { + if activeClear.scope == .authenticationCookies, scope == .allWebsiteData { + let pending = self.makePendingAllDataClear( + after: activeClear.task, + operation: operation + ) + let result = await pending.task.value + self.finishPendingAllDataClear(id: pending.id) + return result + } + + let result = await activeClear.task.value + self.finishActiveClear(id: activeClear.id) + return result + } + + self.nextID &+= 1 + let clearID = self.nextID + let task = Task { @MainActor in + await operation() + } + self.activeClear = ActiveClear( + id: clearID, + scope: scope, + task: task + ) + + let result = await task.value + self.finishActiveClear(id: clearID) + return result + } + + private func makePendingAllDataClear( + after activeTask: Task, + operation: @escaping @MainActor @Sendable () async -> Bool + ) -> PendingAllDataClear { + if let pendingAllDataClear { + return pendingAllDataClear + } + + self.nextID &+= 1 + let clearID = self.nextID + let task = Task { @MainActor in + _ = await activeTask.value + return await operation() + } + let pending = PendingAllDataClear(id: clearID, task: task) + self.pendingAllDataClear = pending + return pending + } + + private func finishActiveClear(id: UInt64) { + guard self.activeClear?.id == id else { return } + self.activeClear = nil + } + + private func finishPendingAllDataClear(id: UInt64) { + guard self.pendingAllDataClear?.id == id else { return } + self.pendingAllDataClear = nil + } +} diff --git a/Sources/Kaset/Services/WebKit/WebKitManager+CookieArchiveStorage.swift b/Sources/Kaset/Services/WebKit/WebKitManager+CookieArchiveStorage.swift new file mode 100644 index 000000000..63211ae3d --- /dev/null +++ b/Sources/Kaset/Services/WebKit/WebKitManager+CookieArchiveStorage.swift @@ -0,0 +1,675 @@ +import Foundation + +// MARK: - CookieBackupRollbackResult + +enum CookieBackupRollbackResult: Equatable, Sendable { + case rolledBack + case cleared + case superseded + case failed +} + +// MARK: - CookieArchiveRollbackState + +private struct CookieArchiveRollbackState: Sendable { + let archiveData: Data? + let wasRestoreAllowed: Bool +} + +// MARK: - CookieArchiveLiveBaseline + +enum CookieArchiveLiveBaseline: Sendable { + case empty + case archive(Data) + case persistedArchive +} + +// MARK: - CookieBackupCommitFence + +private final class CookieBackupCommitFence: @unchecked Sendable { + private let lock = NSLock() + private let disableRestore: @Sendable () -> Bool + private var isRevoked = false + + init(disableRestore: @escaping @Sendable () -> Bool) { + self.disableRestore = disableRestore + } + + func revoke() -> Bool { + self.lock.withLock { + self.isRevoked = true + return self.disableRestore() + } + } + + func restoreIfActive(_ operation: () -> Bool) -> Bool { + self.lock.withLock { + guard !self.isRevoked else { return false } + return operation() + } + } + + var revoked: Bool { + self.lock.withLock { self.isRevoked } + } +} + +// MARK: - CookieBackupTransaction + +/// In-memory rollback handle for one login-cookie persistence attempt. +/// Its archive contents are intentionally inaccessible and unprintable. +struct CookieBackupTransaction: Sendable { + fileprivate let id: UInt64 + fileprivate let rollbackState: CookieArchiveRollbackState + fileprivate let previousLiveArchiveData: Data? + fileprivate let previousLoginCookies: [HTTPCookie] + fileprivate let restorePolicyGeneration: UInt64 + fileprivate let commitFence: CookieBackupCommitFence + fileprivate let previousLiveSnapshotFingerprint: Data? + + var hadPreviousArchive: Bool { + self.rollbackState.archiveData != nil + } + + var wasPreviouslyRestorable: Bool { + self.rollbackState.wasRestoreAllowed + } + + func loginCookiesBeforeAttempt() -> [HTTPCookie] { + self.previousLoginCookies + } + + func hasChangedFromPreviousLiveSnapshot(_ snapshot: CookieArchiveSnapshot) -> Bool { + guard let previousLiveSnapshotFingerprint else { return true } + return snapshot.stabilityFingerprint != previousLiveSnapshotFingerprint + } + + func matches(_ other: CookieBackupTransaction) -> Bool { + self.id == other.id + } + + @discardableResult + func revokeCommit() -> Bool { + self.commitFence.revoke() + } + + var isCommitRevoked: Bool { + self.commitFence.revoked + } + + fileprivate func restoreIfCommitActive(_ operation: () -> Bool) -> Bool { + self.commitFence.restoreIfActive(operation) + } +} + +// MARK: CustomStringConvertible, CustomDebugStringConvertible, CustomReflectable + +#if DEBUG + extension CookieBackupTransaction { + static func testing( + id: UInt64 = 1, + previousLiveSnapshotFingerprint: Data? = nil + ) -> CookieBackupTransaction { + CookieBackupTransaction( + id: id, + rollbackState: CookieArchiveRollbackState( + archiveData: nil, + wasRestoreAllowed: false + ), + previousLiveArchiveData: nil, + previousLoginCookies: [], + restorePolicyGeneration: CookieArchiveRestorePolicy.generation, + commitFence: CookieBackupCommitFence(disableRestore: { true }), + previousLiveSnapshotFingerprint: previousLiveSnapshotFingerprint + ) + } + } +#endif + +// MARK: CustomStringConvertible, CustomDebugStringConvertible, CustomReflectable + +extension CookieBackupTransaction: CustomStringConvertible, CustomDebugStringConvertible, CustomReflectable { + var description: String { + "" + } + + var debugDescription: String { + self.description + } + + var customMirror: Mirror { + Mirror(reflecting: self.description) + } +} + +// MARK: - CookieArchiveRestoreGenerationState + +private final class CookieArchiveRestoreGenerationState: @unchecked Sendable { + private let lock = NSLock() + private var generation: UInt64 = 0 + + func current() -> UInt64 { + self.lock.withLock { self.generation } + } + + func invalidate( + operation: () -> Bool + ) -> Bool { + self.lock.withLock { + self.generation &+= 1 + return operation() + } + } + + func performIfCurrent( + _ expectedGeneration: UInt64, + operation: () -> Bool + ) -> Bool { + self.lock.withLock { + guard self.generation == expectedGeneration else { return false } + return operation() + } + } +} + +// MARK: - CookieArchiveRestoreDecision + +enum CookieArchiveRestoreDecision: Equatable, Sendable { + case allowed + case denied + case unavailable +} + +// MARK: - CookieArchiveRestorePolicy + +enum CookieArchiveRestorePolicy { + private static let invalidatedKey = "authCookieBackupInvalidated" + private static let generationState = CookieArchiveRestoreGenerationState() + + static var generation: UInt64 { + self.generationState.current() + } + + @discardableResult + static func invalidateAndAdvanceGeneration() -> Bool { + self.generationState.invalidate { + self.setRestoreAllowed(false) + } + } + + static func setRestoreAllowed( + _: Bool, + ifGenerationMatches expectedGeneration: UInt64, + operation: () -> Bool + ) -> Bool { + self.generationState.performIfCurrent(expectedGeneration) { + operation() + } + } + + static var restoreDecision: CookieArchiveRestoreDecision { + if UITestConfig.isRunningUnitTests { + return .allowed + } + let invalidationTombstonePresent = UserDefaults.standard.bool(forKey: self.invalidatedKey) + guard !invalidationTombstonePresent else { return .denied } + let restorePolicyGeneration = self.generation + + return self.resolveRestoreDecision( + invalidationTombstonePresent: false, + storedPolicy: CookieRestorePolicyStorage.loadResult(), + archiveResult: { KeychainCookieStorage.loadArchiveResult() }, + migrateLegacyArchive: { archiveData in + self.migrateLegacyArchivePolicy( + archiveData, + expectedGeneration: restorePolicyGeneration + ) + } + ) + } + + static func resolveRestoreDecision( + invalidationTombstonePresent: Bool, + storedPolicy: CookieRestorePolicyLoadResult, + archiveResult: () -> CookieArchiveLoadResult, + migrateLegacyArchive: (Data) -> CookieArchiveRestoreDecision + ) -> CookieArchiveRestoreDecision { + guard !invalidationTombstonePresent else { return .denied } + + switch storedPolicy { + case .allowed: + return .allowed + case .denied: + return .denied + case .failure: + return .unavailable + case .notFound: + switch archiveResult() { + case .notFound: + return .allowed + case let .data(archiveData): + return migrateLegacyArchive(archiveData) + case .failure: + return .unavailable + } + } + } + + private static func migrateLegacyArchivePolicy( + _ archiveData: Data, + expectedGeneration: UInt64 + ) -> CookieArchiveRestoreDecision { + self.migrateLegacyArchivePolicy( + archiveData, + expectedGeneration: expectedGeneration, + savePolicy: { CookieRestorePolicyStorage.save(true) } + ) + } + + static func migrateLegacyArchivePolicy( + _ archiveData: Data, + expectedGeneration: UInt64, + savePolicy: () -> Bool + ) -> CookieArchiveRestoreDecision { + guard KeychainCookieStorage.isRestorableArchiveData(archiveData) else { + return .denied + } + let didSave = self.generationState.performIfCurrent(expectedGeneration) { + guard !UserDefaults.standard.bool(forKey: self.invalidatedKey) else { + return false + } + return savePolicy() + } + guard self.generation == expectedGeneration, + !UserDefaults.standard.bool(forKey: self.invalidatedKey) + else { + return .denied + } + return didSave ? .allowed : .unavailable + } + + @discardableResult + static func setRestoreAllowed(_ allowed: Bool) -> Bool { + if UITestConfig.isRunningUnitTests { + return true + } + if allowed { + UserDefaults.standard.removeObject(forKey: self.invalidatedKey) + } else { + UserDefaults.standard.set(true, forKey: self.invalidatedKey) + } + _ = UserDefaults.standard.synchronize() + return CookieRestorePolicyStorage.save(allowed) + } +} + +// MARK: - Synchronous Restoration Invalidation + +extension WebKitManager { + @discardableResult + func invalidateAuthCookieRestoration() -> Bool { + CookieArchiveRestorePolicy.invalidateAndAdvanceGeneration() + } +} + +// MARK: - CookieArchiveStorage + +struct CookieArchiveStorage: Sendable { + let save: @Sendable (Data, Int) -> Bool + let load: @Sendable () -> Data? + let delete: @Sendable () -> Bool + let restoreDecision: @Sendable () -> CookieArchiveRestoreDecision + let setRestoreAllowed: @Sendable (Bool) -> Bool + let exportsDebugArchive: Bool + let deleteDebugArchive: @Sendable () -> Bool + + init( + save: @escaping @Sendable (Data, Int) -> Bool, + load: @escaping @Sendable () -> Data?, + delete: @escaping @Sendable () -> Bool, + restoreDecision: @escaping @Sendable () -> CookieArchiveRestoreDecision = { .allowed }, + setRestoreAllowed: @escaping @Sendable (Bool) -> Bool = { _ in true }, + exportsDebugArchive: Bool = false, + deleteDebugArchive: @escaping @Sendable () -> Bool = { true } + ) { + self.save = save + self.load = load + self.delete = delete + self.restoreDecision = restoreDecision + self.setRestoreAllowed = setRestoreAllowed + self.exportsDebugArchive = exportsDebugArchive + self.deleteDebugArchive = deleteDebugArchive + } + + static let live = CookieArchiveStorage( + save: { data, cookieCount in + KeychainCookieStorage.saveArchiveData(data, cookieCount: cookieCount) + }, + load: { KeychainCookieStorage.loadArchiveData() }, + delete: { KeychainCookieStorage.deleteCookies() }, + restoreDecision: { CookieArchiveRestorePolicy.restoreDecision }, + setRestoreAllowed: { CookieArchiveRestorePolicy.setRestoreAllowed($0) }, + exportsDebugArchive: true, + deleteDebugArchive: { + LegacyCookieMigration.deleteLegacyFileIfPresent() + } + ) +} + +// MARK: - CookieArchiveGenerationTracker + +struct CookieArchiveGenerationTracker { + private var nextGeneration: UInt64 = 0 + private(set) var latestReservedGeneration: UInt64 = 0 + + mutating func reserveGeneration() -> UInt64 { + self.nextGeneration &+= 1 + self.latestReservedGeneration = self.nextGeneration + return self.nextGeneration + } + + func isLatest(_ generation: UInt64) -> Bool { + generation == self.latestReservedGeneration + } +} + +// MARK: - CookieBackupTransactionOwnership + +enum CookieBackupTransactionOwnership: Equatable, Sendable { + case active + case rollingBack + case none +} + +// MARK: - CookieArchiveWriteQueue + +/// Serializes live WebKit cookie snapshots and discards snapshots superseded +/// before they reach storage. Blocking file/Keychain work runs on this actor, +/// never on `MainActor`. +actor CookieArchiveWriteQueue { + static let shared = CookieArchiveWriteQueue(storage: .live) + + private var generationTracker = CookieArchiveGenerationTracker() + private var nextTransactionID: UInt64 = 0 + private var activeTransactionID: UInt64? + private var rollingBackTransactionID: UInt64? + private var loginTransactionSetupRequiresCleanup = false + private let storage: CookieArchiveStorage + + init(storage: CookieArchiveStorage) { + self.storage = storage + } + + func reserveGeneration() -> UInt64 { + self.generationTracker.reserveGeneration() + } + + func restoreDecision() -> CookieArchiveRestoreDecision { + self.storage.restoreDecision() + } + + func isRestoreAllowed() -> Bool { + self.storage.restoreDecision() == .allowed + } + + func persistedArchiveData() -> Data? { + self.storage.load() + } + + func consumeLoginTransactionSetupCleanupRequirement() -> Bool { + let requiresCleanup = self.loginTransactionSetupRequiresCleanup + self.loginTransactionSetupRequiresCleanup = false + return requiresCleanup + } + + func isActiveLoginTransaction(_ transaction: CookieBackupTransaction) -> Bool { + self.activeTransactionID == transaction.id + } + + func loginTransactionOwnership( + _ transaction: CookieBackupTransaction + ) -> CookieBackupTransactionOwnership { + if self.activeTransactionID == transaction.id { + return .active + } + if self.rollingBackTransactionID == transaction.id { + return .rollingBack + } + return .none + } + + func beginLoginTransaction( + liveBaseline: CookieArchiveLiveBaseline = .persistedArchive, + previousLoginCookies: [HTTPCookie] = [], + previousLiveSnapshotFingerprint: Data? = nil + ) -> CookieBackupTransaction? { + guard self.activeTransactionID == nil, + self.rollingBackTransactionID == nil + else { return nil } + self.loginTransactionSetupRequiresCleanup = false + self.nextTransactionID &+= 1 + let restorePolicyGeneration = CookieArchiveRestorePolicy.generation + let wasRestoreAllowed = self.storage.restoreDecision() == .allowed + let persistedArchiveData = self.storage.load() + let capturedLiveArchiveData: Data? = switch liveBaseline { + case .empty: + nil + case let .archive(data): + data + case .persistedArchive: + persistedArchiveData + } + let rollbackArchiveData = wasRestoreAllowed + ? capturedLiveArchiveData + : persistedArchiveData + let disableRestore = self.storage.setRestoreAllowed + let transaction = CookieBackupTransaction( + id: self.nextTransactionID, + rollbackState: CookieArchiveRollbackState( + archiveData: rollbackArchiveData, + wasRestoreAllowed: wasRestoreAllowed + ), + previousLiveArchiveData: capturedLiveArchiveData, + previousLoginCookies: previousLoginCookies, + restorePolicyGeneration: restorePolicyGeneration, + commitFence: CookieBackupCommitFence( + disableRestore: { + disableRestore(false) + } + ), + previousLiveSnapshotFingerprint: previousLiveSnapshotFingerprint + ) + self.activeTransactionID = transaction.id + _ = self.generationTracker.reserveGeneration() + guard transaction.restorePolicyGeneration == CookieArchiveRestorePolicy.generation else { + self.activeTransactionID = nil + return nil + } + guard self.storage.setRestoreAllowed(false) else { + let didRestorePriorPolicy = CookieArchiveRestorePolicy.setRestoreAllowed( + wasRestoreAllowed, + ifGenerationMatches: transaction.restorePolicyGeneration, + operation: { + self.storage.setRestoreAllowed(wasRestoreAllowed) + } + ) + self.loginTransactionSetupRequiresCleanup = !didRestorePriorPolicy + self.activeTransactionID = nil + return nil + } + return transaction + } + + @discardableResult + func disableLoginTransactionRestore(_ transaction: CookieBackupTransaction) -> Bool { + guard self.activeTransactionID == transaction.id else { return false } + return self.storage.setRestoreAllowed(false) + } + + @discardableResult + func finalizeLoginTransaction(_ transaction: CookieBackupTransaction) -> Bool { + guard self.activeTransactionID == transaction.id, + !Task.isCancelled, + transaction.restorePolicyGeneration == CookieArchiveRestorePolicy.generation + else { + _ = self.storage.setRestoreAllowed(false) + return false + } + guard transaction.restoreIfCommitActive({ + CookieArchiveRestorePolicy.setRestoreAllowed( + true, + ifGenerationMatches: transaction.restorePolicyGeneration, + operation: { + self.storage.setRestoreAllowed(true) + } + ) + }) else { + _ = self.storage.setRestoreAllowed(false) + return false + } + #if DEBUG + if self.storage.exportsDebugArchive { + if let archiveData = self.storage.load() { + DebugCookieFileExporter.exportAuthCookiesArchiveData(archiveData) + } else { + DebugCookieFileExporter.deleteExport() + } + } + #endif + self.activeTransactionID = nil + return true + } + + @discardableResult + func claimLoginTransactionRollback(_ transaction: CookieBackupTransaction) -> Bool { + guard self.activeTransactionID == transaction.id else { return false } + self.activeTransactionID = nil + self.rollingBackTransactionID = transaction.id + _ = self.generationTracker.reserveGeneration() + return true + } + + @discardableResult + func failLoginTransactionRollback(_ transaction: CookieBackupTransaction) -> Bool { + guard self.rollingBackTransactionID == transaction.id else { return false } + self.rollingBackTransactionID = nil + _ = self.generationTracker.reserveGeneration() + return self.storage.setRestoreAllowed(false) + } + + @discardableResult + func rollbackLoginTransaction(_ transaction: CookieBackupTransaction) -> Bool { + guard self.rollingBackTransactionID == transaction.id else { return false } + + let didRestoreArchive: Bool = if let archiveData = transaction.rollbackState.archiveData { + self.storage.save(archiveData, 0) + || self.storage.load() == archiveData + } else { + self.storage.delete() + } + guard didRestoreArchive else { + _ = self.storage.setRestoreAllowed(false) + self.rollingBackTransactionID = nil + return false + } + + let didRestorePolicy = CookieArchiveRestorePolicy.setRestoreAllowed( + transaction.rollbackState.wasRestoreAllowed, + ifGenerationMatches: transaction.restorePolicyGeneration, + operation: { + self.storage.setRestoreAllowed( + transaction.rollbackState.wasRestoreAllowed + ) + } + ) + if !didRestorePolicy { + _ = self.storage.setRestoreAllowed(false) + } + #if DEBUG + if self.storage.exportsDebugArchive { + if didRestorePolicy, + transaction.rollbackState.wasRestoreAllowed, + let archiveData = transaction.rollbackState.archiveData + { + DebugCookieFileExporter.exportAuthCookiesArchiveData(archiveData) + } else { + DebugCookieFileExporter.deleteExport() + } + } + #endif + self.rollingBackTransactionID = nil + return didRestorePolicy + } + + @discardableResult + func abandonLoginTransaction(_ transaction: CookieBackupTransaction) -> Bool { + let ownsTransaction = self.activeTransactionID == transaction.id + || self.rollingBackTransactionID == transaction.id + guard ownsTransaction else { return false } + self.activeTransactionID = nil + self.rollingBackTransactionID = nil + _ = self.generationTracker.reserveGeneration() + return self.storage.setRestoreAllowed(false) + } + + func invalidateAndDeleteIfLatest( + generation: UInt64 + ) -> CookieArchiveSaveResult { + guard self.generationTracker.isLatest(generation), + self.activeTransactionID == nil, + self.rollingBackTransactionID == nil + else { + return .superseded + } + + let didInvalidateRestore = self.storage.setRestoreAllowed(false) + let didDelete = self.storage.delete() + let didDeleteDebugArchive = !self.storage.exportsDebugArchive + || self.storage.deleteDebugArchive() + return didInvalidateRestore && didDelete && didDeleteDebugArchive + ? .saved + : .failed + } + + @discardableResult + func invalidateAndDelete() -> Bool { + self.activeTransactionID = nil + self.rollingBackTransactionID = nil + _ = self.generationTracker.reserveGeneration() + let didInvalidateRestore = self.storage.setRestoreAllowed(false) + let didDelete = self.storage.delete() + let didDeleteDebugArchive = !self.storage.exportsDebugArchive + || self.storage.deleteDebugArchive() + return didInvalidateRestore && didDelete && didDeleteDebugArchive + } + + func save( + archiveData: Data, + cookieCount: Int, + generation: UInt64 + ) -> CookieArchiveSaveResult { + guard self.generationTracker.isLatest(generation) else { + return .superseded + } + + let didSave = self.storage.save(archiveData, cookieCount) + let result: CookieArchiveSaveResult = if didSave { + .saved + } else if self.storage.load() == archiveData { + .alreadyCurrent + } else { + .failed + } + + #if DEBUG + if result.isPersisted, + self.storage.exportsDebugArchive, + self.activeTransactionID == nil, + self.rollingBackTransactionID == nil + { + DebugCookieFileExporter.exportAuthCookiesArchiveData(archiveData) + } + #endif + return result + } +} diff --git a/Sources/Kaset/Services/WebKit/WebKitManager+CookieBackup.swift b/Sources/Kaset/Services/WebKit/WebKitManager+CookieBackup.swift new file mode 100644 index 000000000..d72810adc --- /dev/null +++ b/Sources/Kaset/Services/WebKit/WebKitManager+CookieBackup.swift @@ -0,0 +1,824 @@ +import Foundation +import WebKit + +// MARK: - CookieArchiveBackupAction + +enum CookieArchiveBackupAction: Equatable, Sendable { + case persist(data: Data, cookieCount: Int) + case invalidate + case retainExisting + + static func make(from result: CookieArchiveEncodingResult) -> CookieArchiveBackupAction { + switch result { + case let .archive(data, cookieCount): + .persist(data: data, cookieCount: cookieCount) + case .noPrimarySession: + .invalidate + case .failure: + .retainExisting + } + } +} + +// MARK: - CookieArchiveSnapshot + +struct CookieArchiveSnapshot: Equatable, Sendable { + let data: Data + let cookieCount: Int + let primarySessionValue: String? + let stabilityFingerprint: Data + + static func == (lhs: CookieArchiveSnapshot, rhs: CookieArchiveSnapshot) -> Bool { + lhs.stabilityFingerprint == rhs.stabilityFingerprint + } + + static func make(from cookies: [HTTPCookie]) -> CookieArchiveSnapshot? { + let validCookies = cookies.filter { KeychainCookieStorage.isValidAuthCookie($0) } + guard let archive = KeychainCookieStorage.makeArchiveData(from: validCookies) else { + return nil + } + + let entries = validCookies + .map(CookieArchiveStabilityEntry.init(cookie:)) + .sorted { lhs, rhs in + lhs.sortKey.lexicographicallyPrecedes(rhs.sortKey) + } + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + encoder.dateEncodingStrategy = .millisecondsSince1970 + guard let stabilityFingerprint = try? encoder.encode(entries) else { return nil } + + let youtubeCookies = WebKitManager.cookies( + validCookies, + matching: "youtube.com" + ) + let secureSessionValue = youtubeCookies.first { + $0.name == WebKitManager.authCookieName + }?.value + let fallbackSessionValue = youtubeCookies.first { + $0.name == WebKitManager.fallbackAuthCookieName + }?.value + return CookieArchiveSnapshot( + data: archive.data, + cookieCount: archive.cookieCount, + primarySessionValue: secureSessionValue ?? fallbackSessionValue, + stabilityFingerprint: stabilityFingerprint + ) + } +} + +// MARK: - CookieArchiveVerificationState + +enum CookieArchiveVerificationState: Sendable { + case noValidAuthCookies + case snapshot(CookieArchiveSnapshot) + case invalid + + static func make(from cookies: [HTTPCookie]) -> CookieArchiveVerificationState { + let validCookies = cookies.filter { KeychainCookieStorage.isValidAuthCookie($0) } + guard !validCookies.isEmpty else { return .noValidAuthCookies } + guard let snapshot = CookieArchiveSnapshot.make(from: validCookies) else { return .invalid } + return .snapshot(snapshot) + } + + func matches(_ expected: CookieArchiveVerificationState) -> Bool { + switch (self, expected) { + case (.noValidAuthCookies, .noValidAuthCookies): + true + case let (.snapshot(actual), .snapshot(expected)): + actual == expected + case (.invalid, _), (_, .invalid), (.noValidAuthCookies, .snapshot), (.snapshot, .noValidAuthCookies): + false + } + } +} + +// MARK: - CookieArchiveStabilityEntry + +private struct CookieArchiveStabilityEntry: Codable, Equatable, Sendable { + let name: String + let value: String + let domain: String + let path: String + let expiresDate: Date? + let isSecure: Bool + let isHTTPOnly: Bool + let isSessionOnly: Bool + let sameSitePolicy: String? + + init(cookie: HTTPCookie) { + self.name = cookie.name + self.value = cookie.value + self.domain = cookie.domain.lowercased() + self.path = cookie.path + self.expiresDate = cookie.expiresDate + self.isSecure = cookie.isSecure + self.isHTTPOnly = cookie.isHTTPOnly + self.isSessionOnly = cookie.isSessionOnly + self.sameSitePolicy = cookie.sameSitePolicy?.rawValue.lowercased() ?? "none" + } + + var sortKey: [String] { + [self.domain, self.path, self.name, self.value] + } +} + +// MARK: - LoginCookieVerificationState + +private struct LoginCookieVerificationState: Equatable, Sendable { + let entries: [CookieArchiveStabilityEntry] + + static func make( + from cookies: [HTTPCookie], + expirationCutoff: Date = Date() + ) -> LoginCookieVerificationState { + let entries = cookies + .filter { cookie in + guard KeychainCookieStorage.isLoginDomainCookie(cookie) else { return false } + guard let expiresDate = cookie.expiresDate else { return true } + return expiresDate > expirationCutoff + } + .map(CookieArchiveStabilityEntry.init(cookie:)) + .sorted { lhs, rhs in + lhs.sortKey.lexicographicallyPrecedes(rhs.sortKey) + } + return LoginCookieVerificationState(entries: entries) + } +} + +// MARK: - CookieArchiveWriteAttempt + +struct CookieArchiveWriteAttempt: Sendable { + let snapshot: CookieArchiveSnapshot + let generation: UInt64 +} + +// MARK: - CookieBackupStabilizationResult + +enum CookieBackupStabilizationResult: Equatable { + case persisted + case failed + case cancelled + case unstable +} + +// MARK: - CookieBackupStabilizer + +@MainActor +enum CookieBackupStabilizer { + struct Operations { + let prepareAttempt: () -> Void + let makeAttempt: () async -> CookieArchiveWriteAttempt? + let persist: (CookieArchiveWriteAttempt) async -> CookieArchiveSaveResult + let readVerificationSnapshot: () async -> CookieArchiveSnapshot? + let isDirty: () -> Bool + let canContinue: () -> Bool + } + + static func persistStableSnapshot( + maxAttempts: Int, + operations: Operations + ) async -> CookieBackupStabilizationResult { + guard maxAttempts > 0 else { return .unstable } + + for _ in 0 ..< maxAttempts { + guard operations.canContinue() else { return .cancelled } + operations.prepareAttempt() + + guard let attempt = await operations.makeAttempt() else { + return operations.canContinue() ? .failed : .cancelled + } + guard operations.canContinue() else { return .cancelled } + + let saveResult = await operations.persist(attempt) + switch saveResult { + case .failed: + return .failed + case .superseded: + guard operations.canContinue() else { return .cancelled } + continue + case .saved, .alreadyCurrent: + break + } + + guard operations.canContinue() else { return .cancelled } + guard let verificationSnapshot = await operations.readVerificationSnapshot() else { + return operations.canContinue() ? .failed : .cancelled + } + + // Give already-enqueued observer callbacks a chance to mark the + // snapshot dirty, but verify freshness directly instead of treating + // observer delivery as a synchronization barrier. + await Task.yield() + guard operations.canContinue() else { return .cancelled } + + guard let isStable = await self.isStable( + attempt: attempt.snapshot, + verification: verificationSnapshot, + operations: operations + ) else { + return operations.canContinue() ? .failed : .cancelled + } + if isStable { + return .persisted + } + } + + return .unstable + } + + private static func isStable( + attempt: CookieArchiveSnapshot, + verification: CookieArchiveSnapshot, + operations: Operations + ) async -> Bool? { + guard attempt == verification else { return false } + guard operations.isDirty() else { return true } + operations.prepareAttempt() + guard let postCallbackSnapshot = await operations.readVerificationSnapshot() else { + return nil + } + await Task.yield() + guard !operations.isDirty() else { return false } + return attempt == postCallbackSnapshot + } +} + +// MARK: - LiveCookieRollbackResult + +private enum LiveCookieRollbackResult { + case restored + case superseded + case failed +} + +// MARK: - LoginCookieBackupBaseline + +private struct LoginCookieBackupBaseline { + let liveBaseline: CookieArchiveLiveBaseline + let authSnapshot: CookieArchiveSnapshot? + let loginCookies: [HTTPCookie] +} + +// MARK: - Forced Cookie Backup + +extension WebKitManager { + /// Starts a rollback-safe persistence transaction before the login WebView + /// can change authentication cookies. + func beginLoginCookieBackup() async -> CookieBackupTransaction? { + guard await self.waitForInitialCookieRestore() else { + self.loginCookieBackupSetupRequiresCleanup = true + return nil + } + guard !self.isRestoringCookies, + !self.isClearingAuthCookies, + !self.authCookieClearCoordinator.isBusy, + !self.loginCookieBackupSetupRequiresCleanup, + !self.isPreparingLoginCookieBackup, + self.activeLoginCookieBackupTransaction == nil, + self.forcedCookieBackupTask == nil + else { return nil } + let expectedOperationGeneration = self.authCookieOperationFence.generation + self.isPreparingLoginCookieBackup = true + self.forcedCookieBackupDirty = false + defer { self.isPreparingLoginCookieBackup = false } + self.cookieDebounceTask?.cancel() + self.cookieDebounceTask = nil + + guard let baseline = await self.captureLoginCookieBackupBaseline( + expectedOperationGeneration: expectedOperationGeneration + ) else { return nil } + guard let transaction = await CookieArchiveWriteQueue.shared.beginLoginTransaction( + liveBaseline: baseline.liveBaseline, + previousLoginCookies: baseline.loginCookies, + previousLiveSnapshotFingerprint: baseline.authSnapshot?.stabilityFingerprint + ) else { + if await CookieArchiveWriteQueue.shared + .consumeLoginTransactionSetupCleanupRequirement() + { + self.loginCookieBackupSetupRequiresCleanup = true + _ = await CookieArchiveWriteQueue.shared.invalidateAndDelete() + } + return nil + } + let baselineChanged = await self.loginCookieBackupBaselineChanged(baseline) + guard !self.isClearingAuthCookies, + self.authCookieOperationFence.isCurrent(expectedOperationGeneration), + !Task.isCancelled + else { + if baselineChanged { + await self.abandonUnstableLoginCookieBackup(transaction) + } else if self.isClearingAuthCookies + || !self.authCookieOperationFence.isCurrent(expectedOperationGeneration) + { + _ = await CookieArchiveWriteQueue.shared.abandonLoginTransaction(transaction) + } else if await CookieArchiveWriteQueue.shared.claimLoginTransactionRollback(transaction) { + let didRollback = await CookieArchiveWriteQueue.shared + .rollbackLoginTransaction(transaction) + if !didRollback { + self.loginCookieBackupSetupRequiresCleanup = true + _ = await CookieArchiveWriteQueue.shared.invalidateAndDelete() + } + } + return nil + } + guard !baselineChanged else { + await self.abandonUnstableLoginCookieBackup(transaction) + return nil + } + + self.loginCookieBackupSetupRequiresCleanup = false + self.forcedCookieBackupDirty = false + self.activeLoginCookieBackupTransaction = transaction + self.cookieDebounceTask?.cancel() + self.cookieDebounceTask = nil + return transaction + } + + private func captureLoginCookieBackupBaseline( + expectedOperationGeneration: UInt64 + ) async -> LoginCookieBackupBaseline? { + let allLiveCookies = await self.dataStore.httpCookieStore.allCookies() + guard !self.isClearingAuthCookies, + self.authCookieOperationFence.isCurrent(expectedOperationGeneration), + !Task.isCancelled + else { return nil } + + let authCookies = allLiveCookies.filter(KeychainCookieStorage.isAuthCookie) + let loginCookies = allLiveCookies.filter(KeychainCookieStorage.isLoginDomainCookie) + if authCookies.isEmpty { + return LoginCookieBackupBaseline( + liveBaseline: .empty, + authSnapshot: nil, + loginCookies: loginCookies + ) + } + guard let snapshot = CookieArchiveSnapshot.make(from: authCookies) else { + self.loginCookieBackupSetupRequiresCleanup = true + return nil + } + return LoginCookieBackupBaseline( + liveBaseline: .archive(snapshot.data), + authSnapshot: snapshot, + loginCookies: loginCookies + ) + } + + private func loginCookieBackupBaselineChanged( + _ baseline: LoginCookieBackupBaseline + ) async -> Bool { + var baselineChanged = true + for _ in 0 ..< 3 { + self.forcedCookieBackupDirty = false + let verificationCookies = await self.dataStore.httpCookieStore.allCookies() + baselineChanged = !Self.loginCookieBaselineMatches( + expectedAuthSnapshot: baseline.authSnapshot, + expectedLoginCookies: baseline.loginCookies, + currentCookies: verificationCookies + ) + if baselineChanged || !self.forcedCookieBackupDirty { + break + } + } + return baselineChanged + } + + private func abandonUnstableLoginCookieBackup( + _ transaction: CookieBackupTransaction + ) async { + let didAbandon = await CookieArchiveWriteQueue.shared + .abandonLoginTransaction(transaction) + self.loginCookieBackupSetupRequiresCleanup = true + if !didAbandon { + _ = await CookieArchiveWriteQueue.shared.invalidateAndDelete() + } + } + + static func loginCookieBaselineMatches( + expectedAuthSnapshot: CookieArchiveSnapshot?, + expectedLoginCookies: [HTTPCookie], + currentCookies: [HTTPCookie] + ) -> Bool { + let currentAuthCookies = currentCookies.filter(KeychainCookieStorage.isAuthCookie) + let currentAuthSnapshot = CookieArchiveSnapshot.make(from: currentAuthCookies) + let authSnapshotMatches = expectedAuthSnapshot?.stabilityFingerprint + == currentAuthSnapshot?.stabilityFingerprint + let expectedLoginState = LoginCookieVerificationState.make(from: expectedLoginCookies) + let currentLoginState = LoginCookieVerificationState.make(from: currentCookies) + return authSnapshotMatches && expectedLoginState == currentLoginState + } + + func isLoginCookieBackupActive(_ transaction: CookieBackupTransaction) async -> Bool { + let queueOwnsTransaction = await CookieArchiveWriteQueue.shared + .isActiveLoginTransaction(transaction) + return self.isCurrentLoginCookieBackup(transaction) && queueOwnsTransaction + } + + func hasLoginCookieSnapshotChanged(_ transaction: CookieBackupTransaction) async -> Bool { + guard self.isCurrentLoginCookieBackup(transaction), + !Task.isCancelled, + let snapshot = await self.currentCookieArchiveSnapshot() + else { return false } + return transaction.hasChangedFromPreviousLiveSnapshot(snapshot) + } + + /// Captures and persists a fresh stable snapshot while the transaction keeps + /// startup restoration disabled. + func refreshLoginCookieBackup(_ transaction: CookieBackupTransaction) async -> Bool { + guard self.isCurrentLoginCookieBackup(transaction) else { return false } + self.cookieDebounceTask?.cancel() + self.cookieDebounceTask = nil + let didPersist = await self.forceBackupCookies() + return didPersist && self.isCurrentLoginCookieBackup(transaction) + } + + @discardableResult + func commitLoginCookieBackup( + _ transaction: CookieBackupTransaction + ) async -> String? { + for _ in 0 ..< 5 { + guard self.isCurrentLoginCookieBackup(transaction), + !transaction.isCommitRevoked, + !Task.isCancelled + else { + return nil + } + guard let attempt = await self.makeCurrentCookieArchiveWriteAttempt(), + let committedSessionValue = attempt.snapshot.primarySessionValue + else { + return nil + } + let saveResult = await self.persistCookieArchiveAttempt(attempt) + guard saveResult.isPersisted, + self.isCurrentLoginCookieBackup(transaction), + !Task.isCancelled, + let verificationSnapshot = await self.currentCookieArchiveSnapshot(), + !Task.isCancelled, + attempt.snapshot == verificationSnapshot + else { + continue + } + guard self.isCurrentLoginCookieBackup(transaction), + !Task.isCancelled, + let postCommitSnapshot = await self.currentCookieArchiveSnapshot(), + attempt.snapshot == postCommitSnapshot + else { + _ = await CookieArchiveWriteQueue.shared + .disableLoginTransactionRestore(transaction) + return nil + } + return committedSessionValue + } + + self.logger.error("Authentication cookies changed repeatedly during login commit") + return nil + } + + func finalizeLoginCookieBackup( + _ transaction: CookieBackupTransaction + ) async -> String? { + guard self.isCurrentLoginCookieBackup(transaction), + !Task.isCancelled, + await CookieArchiveWriteQueue.shared + .disableLoginTransactionRestore(transaction), + await self.commitLoginCookieBackup(transaction) != nil + else { + return nil + } + if self.forcedCookieBackupDirty { + guard let refreshedSnapshot = await self.refreshPersistedSnapshot(), + transaction.hasChangedFromPreviousLiveSnapshot(refreshedSnapshot) + else { + return nil + } + } + guard self.isCurrentLoginCookieBackup(transaction), + !Task.isCancelled + else { return nil } + + // Persist and re-read one last stable snapshot while restoration remains + // disabled. Finalization enables restoration as its last atomic step, so + // cancellation can never make an unconfirmed login restorable. + guard let postFinalizeSnapshot = await self.refreshPersistedSnapshot(), + transaction.hasChangedFromPreviousLiveSnapshot(postFinalizeSnapshot), + let postFinalizeSessionValue = postFinalizeSnapshot.primarySessionValue, + self.isCurrentLoginCookieBackup(transaction), + !Task.isCancelled + else { return nil } + + let didFinalize = await CookieArchiveWriteQueue.shared.finalizeLoginTransaction(transaction) + guard didFinalize, + self.isCurrentLoginCookieBackup(transaction), + !self.forcedCookieBackupDirty, + !Task.isCancelled + else { + if didFinalize { + _ = self.invalidateAuthCookieRestoration() + self.activeLoginCookieBackupTransaction = nil + } + return nil + } + + self.activeLoginCookieBackupTransaction = nil + return postFinalizeSessionValue + } + + private func refreshPersistedSnapshot() async -> CookieArchiveSnapshot? { + for _ in 0 ..< 3 { + self.forcedCookieBackupDirty = false + guard await self.forceBackupCookies(), + let archiveData = await CookieArchiveWriteQueue.shared.persistedArchiveData() + else { + return nil + } + guard !self.forcedCookieBackupDirty else { continue } + let cookies = KeychainCookieStorage.decodeCookies(from: archiveData) + return CookieArchiveSnapshot.make(from: cookies) + } + return nil + } + + func prepareLoginCookieBackupRollback( + _ transaction: CookieBackupTransaction + ) async -> Bool { + guard self.isCurrentLoginCookieBackup(transaction) else { + return true + } + if await CookieArchiveWriteQueue.shared.disableLoginTransactionRestore(transaction) { + return true + } + guard self.isCurrentLoginCookieBackup(transaction) else { + return true + } + return self.invalidateAuthCookieRestoration() + } + + func rollbackLoginCookieBackup( + _ transaction: CookieBackupTransaction + ) async -> CookieBackupRollbackResult { + guard self.isCurrentLoginCookieBackup(transaction) else { + return .superseded + } + guard await CookieArchiveWriteQueue.shared.claimLoginTransactionRollback(transaction) else { + let ownership = await CookieArchiveWriteQueue.shared + .loginTransactionOwnership(transaction) + return ownership == .none ? .failed : .superseded + } + + self.cookieDebounceTask?.cancel() + self.cookieDebounceTask = nil + let wasClearingAuthCookies = self.isClearingAuthCookies + self.isClearingAuthCookies = true + defer { + if self.isCurrentLoginCookieBackup(transaction) { + self.isClearingAuthCookies = wasClearingAuthCookies + } + } + + let expirationCutoff = Date() + let previousCookies = transaction.loginCookiesBeforeAttempt().filter { cookie in + guard let expiresDate = cookie.expiresDate else { return true } + return expiresDate > expirationCutoff + } + let expectedState = LoginCookieVerificationState.make( + from: previousCookies, + expirationCutoff: expirationCutoff + ) + switch await self.restoreLiveCookies( + previousCookies, + expectedState: expectedState, + expirationCutoff: expirationCutoff, + transaction: transaction + ) { + case .restored: + break + case .superseded: + return .superseded + case .failed: + return await self.failLoginCookieBackupRollback( + transaction, + restoringClearingStateTo: wasClearingAuthCookies + ) + } + + let didRollback = await CookieArchiveWriteQueue.shared.rollbackLoginTransaction(transaction) + guard self.isCurrentLoginCookieBackup(transaction) else { return .superseded } + guard didRollback else { + self.isClearingAuthCookies = wasClearingAuthCookies + self.activeLoginCookieBackupTransaction = nil + self.logger.error("Could not restore the prior cookie backup after login cancellation") + return .failed + } + + let postRollbackCookies = await self.dataStore.httpCookieStore.allCookies() + guard self.isCurrentLoginCookieBackup(transaction) else { return .superseded } + let postRollbackState = LoginCookieVerificationState.make( + from: postRollbackCookies, + expirationCutoff: expirationCutoff + ) + guard postRollbackState == expectedState else { + _ = await CookieArchiveWriteQueue.shared.invalidateAndDelete() + self.isClearingAuthCookies = wasClearingAuthCookies + self.activeLoginCookieBackupTransaction = nil + self.logger.error("Authentication cookies changed during login rollback") + return .failed + } + + self.isClearingAuthCookies = wasClearingAuthCookies + self.activeLoginCookieBackupTransaction = nil + return .rolledBack + } + + private func restoreLiveCookies( + _ previousCookies: [HTTPCookie], + expectedState: LoginCookieVerificationState, + expirationCutoff: Date, + transaction: CookieBackupTransaction + ) async -> LiveCookieRollbackResult { + for _ in 0 ..< 3 { + let currentCookies = await self.dataStore.httpCookieStore.allCookies() + guard self.isCurrentLoginCookieBackup(transaction) else { return .superseded } + for cookie in currentCookies where KeychainCookieStorage.isLoginDomainCookie(cookie) { + await self.dataStore.httpCookieStore.deleteCookie(cookie) + guard self.isCurrentLoginCookieBackup(transaction) else { return .superseded } + } + for cookie in previousCookies { + await self.dataStore.httpCookieStore.setCookie(cookie) + guard self.isCurrentLoginCookieBackup(transaction) else { return .superseded } + } + await Task.yield() + let verificationCookies = await self.dataStore.httpCookieStore.allCookies() + let verificationState = LoginCookieVerificationState.make( + from: verificationCookies, + expirationCutoff: expirationCutoff + ) + if verificationState == expectedState { + return .restored + } + } + return .failed + } + + private func failLoginCookieBackupRollback( + _ transaction: CookieBackupTransaction, + restoringClearingStateTo wasClearingAuthCookies: Bool + ) async -> CookieBackupRollbackResult { + _ = await CookieArchiveWriteQueue.shared.failLoginTransactionRollback(transaction) + self.isClearingAuthCookies = wasClearingAuthCookies + self.activeLoginCookieBackupTransaction = nil + return .failed + } + + private func isCurrentLoginCookieBackup(_ transaction: CookieBackupTransaction) -> Bool { + self.activeLoginCookieBackupTransaction?.matches(transaction) == true + } + + /// Forces an immediate save of a stable YouTube/Google cookie snapshot. + func forceBackupCookies() async -> Bool { + guard !self.isClearingAuthCookies else { return false } + + if let forcedCookieBackupTask { + // A later caller establishes a newer freshness boundary even if + // WebKit has not delivered its cookie-change callback yet. + self.forcedCookieBackupDirty = true + return await forcedCookieBackupTask.value + } + + self.cookieDebounceTask?.cancel() + self.cookieDebounceTask = nil + let task = Task { @MainActor [weak self] in + guard let self else { return false } + defer { self.forcedCookieBackupTask = nil } + + let result = await CookieBackupStabilizer.persistStableSnapshot( + maxAttempts: 5, + operations: CookieBackupStabilizer.Operations( + prepareAttempt: { + self.forcedCookieBackupDirty = false + }, + makeAttempt: { + await self.makeCurrentCookieArchiveWriteAttempt() + }, + persist: { attempt in + await self.persistCookieArchiveAttempt(attempt) + }, + readVerificationSnapshot: { + await self.currentCookieArchiveSnapshot() + }, + isDirty: { + self.forcedCookieBackupDirty + }, + canContinue: { + !Task.isCancelled && !self.isClearingAuthCookies + } + ) + ) + + switch result { + case .persisted: + // A callback that lands after the stabilizer's final read remains + // dirty; fail closed so the caller retries instead of erasing it. + return !self.forcedCookieBackupDirty + case .cancelled: + return false + case .failed: + self.logger.error("Forced cookie backup failed") + return false + case .unstable: + self.logger.error("Forced cookie backup did not reach a stable snapshot") + return false + } + } + self.forcedCookieBackupTask = task + return await task.value + } + + private func makeCurrentCookieArchiveWriteAttempt() async -> CookieArchiveWriteAttempt? { + let generation = await CookieArchiveWriteQueue.shared.reserveGeneration() + guard let snapshot = await self.currentCookieArchiveSnapshot() else { return nil } + return CookieArchiveWriteAttempt(snapshot: snapshot, generation: generation) + } + + private func currentCookieArchiveSnapshot() async -> CookieArchiveSnapshot? { + let cookies = await self.dataStore.httpCookieStore.allCookies() + let authCookies = cookies.filter(KeychainCookieStorage.isAuthCookie) + return CookieArchiveSnapshot.make(from: authCookies) + } + + private func persistCookieArchiveAttempt( + _ attempt: CookieArchiveWriteAttempt + ) async -> CookieArchiveSaveResult { + await CookieArchiveWriteQueue.shared.save( + archiveData: attempt.snapshot.data, + cookieCount: attempt.snapshot.cookieCount, + generation: attempt.generation + ) + } +} + +// MARK: - WebKitManager + WKHTTPCookieStoreObserver + +extension WebKitManager: WKHTTPCookieStoreObserver { + nonisolated func cookiesDidChange(in cookieStore: WKHTTPCookieStore) { + Task { @MainActor in + self.handleObservedCookieChange(in: cookieStore) + } + } + + func handleObservedCookieChange(in cookieStore: WKHTTPCookieStore) { + self.recordCookieChange() + + guard !self.isRestoringCookies, !self.isClearingAuthCookies else { return } + if self.isPreparingLoginCookieBackup + || self.activeLoginCookieBackupTransaction != nil + || self.forcedCookieBackupTask != nil + { + self.forcedCookieBackupDirty = true + return + } + + // WebKit fires once per individual cookie change, so debounce the backup. + self.cookieDebounceTask?.cancel() + self.cookieDebounceTask = Task { + do { + try await Task.sleep(for: Self.cookieDebounceInterval) + } catch is CancellationError { + return + } catch { + self.logger.warning("Unexpected error during cookie debounce: \(error.localizedDescription)") + } + + guard !Task.isCancelled else { return } + await self.performCookieBackup(cookieStore: cookieStore) + } + } + + private func performCookieBackup(cookieStore: WKHTTPCookieStore) async { + guard !self.isClearingAuthCookies else { return } + let generation = await CookieArchiveWriteQueue.shared.reserveGeneration() + let cookies = await cookieStore.allCookies() + guard !Task.isCancelled, !self.isClearingAuthCookies else { return } + let authCookies = cookies.filter(KeychainCookieStorage.isAuthCookie) + let action = CookieArchiveBackupAction.make( + from: KeychainCookieStorage.makeArchiveResult(from: authCookies) + ) + switch action { + case .invalidate: + let result = await CookieArchiveWriteQueue.shared.invalidateAndDeleteIfLatest( + generation: generation + ) + if result == .failed { + self.logger.error("Could not invalidate an empty authentication-cookie snapshot") + } + case .retainExisting: + self.logger.error("Retaining the last authentication-cookie archive after serialization failure") + case let .persist(data, cookieCount): + guard !Task.isCancelled, !self.isClearingAuthCookies else { return } + _ = await CookieArchiveWriteQueue.shared.save( + archiveData: data, + cookieCount: cookieCount, + generation: generation + ) + } + } +} diff --git a/Sources/Kaset/Services/WebKit/WebKitManager+CookieRestore.swift b/Sources/Kaset/Services/WebKit/WebKitManager+CookieRestore.swift new file mode 100644 index 000000000..c940ec4c8 --- /dev/null +++ b/Sources/Kaset/Services/WebKit/WebKitManager+CookieRestore.swift @@ -0,0 +1,186 @@ +import Foundation +import WebKit + +// MARK: - Startup Cookie Restoration + +extension WebKitManager { + /// Restores auth cookies from Keychain to WebKit. + /// Handles migration from legacy file-based storage on first run. + func restoreAuthCookiesFromBackup(expectedGeneration: UInt64) async -> Bool { + self.isRestoringCookies = true + defer { self.isRestoringCookies = false } + + guard self.canContinueAuthCookieOperation(expectedGeneration: expectedGeneration) else { return false } + + // Wait a moment for WebKit to fully initialize + try? await Task.sleep(for: .milliseconds(100)) + guard self.canContinueAuthCookieOperation(expectedGeneration: expectedGeneration) else { return false } + + switch await CookieArchiveWriteQueue.shared.restoreDecision() { + case .allowed: + break + case .denied: + self.logger.info("Cookie backup restoration is disabled after explicit invalidation") + let didDeletePersistedCookies = await CookieArchiveWriteQueue.shared.invalidateAndDelete() + guard self.canContinueAuthCookieOperation(expectedGeneration: expectedGeneration) else { return false } + let didClearLiveCookies = await self.clearLiveLoginSessionCookies( + expectedGeneration: expectedGeneration + ) + return didDeletePersistedCookies && didClearLiveCookies + case .unavailable: + self.logger.error("Cookie restore policy could not be read; preserving the archive for retry") + _ = await self.clearLiveLoginSessionCookies( + expectedGeneration: expectedGeneration + ) + return false + } + + // Migrate from legacy file-based storage if needed (one-time operation). + // Perform file I/O off the main actor. + _ = await Task(priority: .utility) { + LegacyCookieMigration.migrateIfNeeded() + }.value + guard self.canContinueAuthCookieOperation(expectedGeneration: expectedGeneration) else { return false } + + let existingCookies = await self.dataStore.httpCookieStore.allCookies() + guard self.canContinueAuthCookieOperation(expectedGeneration: expectedGeneration) else { return false } + self.logger.info("WebKit has \(existingCookies.count) cookies on startup") + + // Load cookies from Keychain. + // Perform Keychain I/O off the main actor; decode on main actor. + let archiveResult = await Task(priority: .utility) { + KeychainCookieStorage.loadArchiveResult() + }.value + guard self.canContinueAuthCookieOperation(expectedGeneration: expectedGeneration) else { return false } + + switch archiveResult { + case .failure: + self.logger.error("Cookie backup storage could not be read; preserving it for retry") + _ = await self.clearLiveLoginSessionCookies( + expectedGeneration: expectedGeneration + ) + return false + case .notFound: + guard await self.clearLiveLoginSessionCookies( + expectedGeneration: expectedGeneration + ) else { return false } + self.logger.info("No cookies found in Keychain (first run or signed out)") + return true + case let .data(archiveData): + // The persisted archive is the source of truth for login-session + // state. Preserve unrelated Google/YouTube preference cookies. + guard await self.clearLiveLoginSessionCookies( + expectedGeneration: expectedGeneration + ) else { return false } + return await self.restoreArchivedAuthCookies( + archiveData, + expectedGeneration: expectedGeneration + ) + } + } + + private func restoreArchivedAuthCookies( + _ archiveData: Data, + expectedGeneration: UInt64 + ) async -> Bool { + let keychainCookies = KeychainCookieStorage.decodeCookies(from: archiveData) + guard !keychainCookies.isEmpty else { + self.logger.error("Cookie archive contained no restorable authentication cookies") + _ = await self.quarantineFailedAuthCookieRestore( + expectedGeneration: expectedGeneration + ) + return false + } + let expectedState = CookieArchiveVerificationState.make(from: keychainCookies) + guard case .snapshot = expectedState else { + self.logger.error("Cookie archive could not produce a verifiable authentication snapshot") + _ = await self.quarantineFailedAuthCookieRestore( + expectedGeneration: expectedGeneration + ) + return false + } + let expectedPrimarySession = keychainCookies.contains { cookie in + KeychainCookieStorage.isValidAuthCookie(cookie) + && (cookie.name == "SAPISID" || cookie.name == "__Secure-3PAPISID") + } + guard expectedPrimarySession else { + self.logger.error("Cookie archive does not contain a primary authentication cookie") + _ = await self.quarantineFailedAuthCookieRestore( + expectedGeneration: expectedGeneration + ) + return false + } + + #if DEBUG + DebugCookieFileExporter.exportAuthCookiesArchiveData(archiveData) + #endif + + self.logger.info("Restoring \(keychainCookies.count) auth cookies from Keychain") + for cookie in keychainCookies { + guard self.canContinueAuthCookieOperation(expectedGeneration: expectedGeneration) else { return false } + await self.dataStore.httpCookieStore.setCookie(cookie) + } + guard self.canContinueAuthCookieOperation(expectedGeneration: expectedGeneration) else { return false } + + let cookies = await self.dataStore.httpCookieStore.allCookies() + guard self.canContinueAuthCookieOperation(expectedGeneration: expectedGeneration) else { return false } + let restoredState = CookieArchiveVerificationState.make(from: cookies) + let didRestore = restoredState.matches(expectedState) + guard didRestore else { + self.logger.error("✗ Failed to restore auth cookies - Keychain data may be corrupted") + _ = await self.quarantineFailedAuthCookieRestore( + expectedGeneration: expectedGeneration + ) + return false + } + self.logger.info("✓ Auth cookies restored from Keychain (\(cookies.count) total cookies)") + + #if DEBUG + if self.canContinueAuthCookieOperation(expectedGeneration: expectedGeneration) { + _ = await self.forceBackupCookies() + } + #endif + return true + } + + func quarantineFailedAuthCookieRestore(expectedGeneration: UInt64) async -> Bool { + guard self.canContinueAuthCookieOperation(expectedGeneration: expectedGeneration) else { + return false + } + let didInvalidateRestore = self.invalidateAuthCookieRestoration() + guard self.canContinueAuthCookieOperation(expectedGeneration: expectedGeneration) else { + return false + } + let didClearLiveCookies = await self.clearLiveLoginSessionCookies( + expectedGeneration: expectedGeneration + ) + if !didInvalidateRestore || !didClearLiveCookies { + self.logger.error("Failed to fully quarantine an invalid authentication-cookie restore") + } + return didInvalidateRestore && didClearLiveCookies + } + + private func clearLiveLoginSessionCookies(expectedGeneration: UInt64) async -> Bool { + for _ in 0 ..< 3 { + guard self.canContinueAuthCookieOperation(expectedGeneration: expectedGeneration) else { return false } + let liveCookies = await self.dataStore.httpCookieStore.allCookies() + for cookie in liveCookies where KeychainCookieStorage.isLoginSessionCookie(cookie) { + await self.dataStore.httpCookieStore.deleteCookie(cookie) + } + guard self.canContinueAuthCookieOperation(expectedGeneration: expectedGeneration) else { return false } + let remainingCookies = await self.dataStore.httpCookieStore.allCookies() + if !remainingCookies.contains(where: KeychainCookieStorage.isLoginSessionCookie) { + return true + } + await Task.yield() + } + + guard self.canContinueAuthCookieOperation(expectedGeneration: expectedGeneration) else { return false } + self.logger.error("Could not clear live login-session cookies from WebKit") + return false + } + + private func canContinueAuthCookieOperation(expectedGeneration: UInt64) -> Bool { + !Task.isCancelled && self.authCookieOperationFence.isCurrent(expectedGeneration) + } +} diff --git a/Sources/Kaset/Services/WebKit/WebKitManager+Cookies.swift b/Sources/Kaset/Services/WebKit/WebKitManager+Cookies.swift index cdbd066b5..ebe96d8c8 100644 --- a/Sources/Kaset/Services/WebKit/WebKitManager+Cookies.swift +++ b/Sources/Kaset/Services/WebKit/WebKitManager+Cookies.swift @@ -49,6 +49,44 @@ final class CookieArchiveWriteCoordinator: @unchecked Sendable { } } +// MARK: - CookieArchiveSaveResult + +enum CookieArchiveSaveResult { + case saved + case alreadyCurrent + case superseded + case failed + + var isPersisted: Bool { + self == .saved || self == .alreadyCurrent + } +} + +// MARK: - CookieArchiveLoadResult + +enum CookieArchiveLoadResult: Sendable { + case data(Data) + case notFound + case failure +} + +// MARK: - CookieRestorePolicyLoadResult + +enum CookieRestorePolicyLoadResult: Equatable, Sendable { + case allowed + case denied + case notFound + case failure +} + +// MARK: - CookieArchiveEncodingResult + +enum CookieArchiveEncodingResult: Equatable, Sendable { + case archive(data: Data, cookieCount: Int) + case noPrimarySession + case failure +} + // MARK: - KeychainCookieStorage /// Stores auth cookie backups in the configured backing store. @@ -62,7 +100,7 @@ enum KeychainCookieStorage { private static let writeCoordinator = CookieArchiveWriteCoordinator() /// Keychain service identifier for cookie storage. - private static let service = "com.kaset.auth-cookies" + fileprivate static let service = "com.kaset.auth-cookies" /// Keychain account identifier. private static let account = "youtube-music-cookies" @@ -70,19 +108,59 @@ enum KeychainCookieStorage { #if DEBUG private static let debugCookieStorageEnvironmentKey = "KASET_DEBUG_COOKIE_STORAGE" - private static var usesDebugFileStorage: Bool { + fileprivate static var usesDebugFileStorage: Bool { ProcessInfo.processInfo.environment[debugCookieStorageEnvironmentKey]?.lowercased() != "keychain" } #endif - /// Cookie names required for YouTube Music authentication. + /// Explicit YouTube/Google session-cookie allowlist used for native API auth. static let authCookieNames = Set([ "SAPISID", "__Secure-3PAPISID", "__Secure-1PAPISID", "SID", "HSID", "SSID", "APISID", + "LOGIN_INFO", "SIDCC", + "__Secure-1PSID", "__Secure-3PSID", + "__Secure-1PSIDCC", "__Secure-3PSIDCC", + "__Secure-1PSIDTS", "__Secure-3PSIDTS", ]) + static let loginSessionCookieNames = authCookieNames.union([ + "LSID", "ACCOUNT_CHOOSER", "GAPS", "__Host-GAPS", + "__Host-1PLSID", "__Host-3PLSID", + "__Secure-1PLSID", "__Secure-3PLSID", + "SMSV", + ]) + + static func isAllowedAuthCookieDomain(_ domain: String) -> Bool { + let normalized = domain + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .drop(while: { $0 == "." }) + guard !normalized.isEmpty else { return false } + + return ["youtube.com", "google.com"].contains { rootDomain in + normalized == rootDomain || normalized.hasSuffix(".\(rootDomain)") + } + } + + static func isAuthCookie(_ cookie: HTTPCookie) -> Bool { + self.authCookieNames.contains(cookie.name) + && self.isAllowedAuthCookieDomain(cookie.domain) + } + + /// Broader in-memory login-transaction boundary. These cookies are never + /// written to the native API archive; they are captured only so a cancelled + /// login can restore the complete Google/YouTube WebKit cookie jar. + static func isLoginDomainCookie(_ cookie: HTTPCookie) -> Bool { + self.isAllowedAuthCookieDomain(cookie.domain) + } + + static func isLoginSessionCookie(_ cookie: HTTPCookie) -> Bool { + self.loginSessionCookieNames.contains(cookie.name) + && self.isAllowedAuthCookieDomain(cookie.domain) + } + static func isValidAuthCookie(_ cookie: HTTPCookie, now: Date = Date()) -> Bool { - guard self.authCookieNames.contains(cookie.name) else { return false } + guard self.isAuthCookie(cookie) else { return false } if let expiresDate = cookie.expiresDate, expiresDate < now { return false } @@ -90,42 +168,82 @@ enum KeychainCookieStorage { } /// Creates the serialized archive persisted by the active cookie backup store. - /// Returns nil if there are no valid auth cookies to store. - static func makeArchiveData(from cookies: [HTTPCookie]) -> (data: Data, cookieCount: Int)? { + static func makeArchiveResult(from cookies: [HTTPCookie]) -> CookieArchiveEncodingResult { + self.makeArchiveResult( + from: cookies, + serializeCookie: { properties in + try? NSKeyedArchiver.archivedData( + withRootObject: properties, + requiringSecureCoding: false + ) + }, + serializeArchive: { cookieData in + try? NSKeyedArchiver.archivedData( + withRootObject: cookieData as NSArray, + requiringSecureCoding: true + ) + } + ) + } + + static func makeArchiveResult( + from cookies: [HTTPCookie], + serializeCookie: ([String: Any]) -> Data?, + serializeArchive: ([Data]) -> Data? + ) -> CookieArchiveEncodingResult { let now = Date() - let authCookies = cookies.filter { cookie in - Self.isValidAuthCookie(cookie, now: now) - } + let authCookies = cookies + .filter { cookie in + Self.isValidAuthCookie(cookie, now: now) + } + .sorted { lhs, rhs in + let lhsKey = [lhs.domain.lowercased(), lhs.path, lhs.name] + let rhsKey = [rhs.domain.lowercased(), rhs.path, rhs.name] + return lhsKey.lexicographicallyPrecedes(rhsKey) + } - guard !authCookies.isEmpty else { return nil } + let youtubeCookies = WebKitManager.cookies(authCookies, matching: "youtube.com") + let hasPrimarySession = youtubeCookies.contains { cookie in + cookie.name == WebKitManager.authCookieName + || cookie.name == WebKitManager.fallbackAuthCookieName + } + guard hasPrimarySession else { return .noPrimarySession } - let cookieData = authCookies.compactMap { cookie -> Data? in - guard let properties = cookie.properties else { return nil } + var cookieData: [Data] = [] + cookieData.reserveCapacity(authCookies.count) + for cookie in authCookies { + guard let properties = cookie.properties else { + Self.logger.error("Cookie properties were unavailable during backup serialization") + return .failure + } var stringProperties: [String: Any] = [:] for (key, value) in properties { stringProperties[key.rawValue] = value } - // Note: Cookie properties dictionary contains types like String, Date, Number, Bool - // which all support NSSecureCoding. However, using requiringSecureCoding: false here - // because [String: Any] doesn't directly conform to NSSecureCoding. - // The unarchive side uses explicit class allowlists for security. - return try? NSKeyedArchiver.archivedData( - withRootObject: stringProperties, - requiringSecureCoding: false - ) - } - - guard !cookieData.isEmpty, - let data = try? NSKeyedArchiver.archivedData( - withRootObject: cookieData as NSArray, - requiringSecureCoding: true - ) - else { + // Cookie properties contain Foundation value types. Secure coding is + // enforced by the explicit class allowlist on the unarchive side. + guard let data = serializeCookie(stringProperties) else { + Self.logger.error("Failed to serialize a cookie for backup storage") + return .failure + } + cookieData.append(data) + } + + guard let data = serializeArchive(cookieData) else { Self.logger.error("Failed to serialize cookies for backup storage") - return nil + return .failure } - return (data: data, cookieCount: cookieData.count) + return .archive(data: data, cookieCount: cookieData.count) + } + + /// Compatibility wrapper for callers that treat an absent session and an + /// encoding failure identically without deleting the last persisted archive. + static func makeArchiveData(from cookies: [HTTPCookie]) -> (data: Data, cookieCount: Int)? { + guard case let .archive(data, cookieCount) = self.makeArchiveResult(from: cookies) else { + return nil + } + return (data: data, cookieCount: cookieCount) } /// Saves YouTube auth cookies to the active cookie backup store. @@ -135,7 +253,7 @@ enum KeychainCookieStorage { _ = Self.saveArchiveData(archive.data, cookieCount: archive.cookieCount) } - private static var debugCookieFileURL: URL? { + fileprivate static var debugCookieFileURL: URL? { guard let appSupport = FileManager.default.urls( for: .applicationSupportDirectory, in: .userDomainMask @@ -150,17 +268,17 @@ enum KeychainCookieStorage { /// Saves an already-serialized cookie archive to the active cookie backup store. @discardableResult static func saveArchiveData(_ data: Data, cookieCount: Int) -> Bool { + guard self.writeCoordinator.beginSaveIfNeeded(data) else { + self.logger.debug("Skipping cookie save because archive is already saved or a write is in progress") + return false + } + #if DEBUG if self.usesDebugFileStorage { return self.saveArchiveDataToDebugFile(data, cookieCount: cookieCount) } #endif - guard self.writeCoordinator.beginSaveIfNeeded(data) else { - self.logger.debug("Skipping Keychain cookie save because archive is already saved or a write is in progress") - return false - } - // Update existing item or add new one (atomic upsert) let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, @@ -199,7 +317,7 @@ enum KeychainCookieStorage { private static func saveArchiveDataToDebugFile(_ data: Data, cookieCount: Int) -> Bool { guard let fileURL = debugCookieFileURL else { self.logger.error("Debug cookie file storage is unavailable") - self.writeCoordinator.seedPersistedArchive(nil) + self.writeCoordinator.finishSave(data, success: false) return false } @@ -217,6 +335,7 @@ enum KeychainCookieStorage { Self.logger.info("Saved \(cookieCount) auth cookies to debug file storage") return true } catch { + self.writeCoordinator.finishSave(data, success: false) Self.logger.error("Failed to save debug cookies file: \(error.localizedDescription)") return false } @@ -246,9 +365,14 @@ enum KeychainCookieStorage { /// Loads the raw serialized cookie archive data from the active backing store. static func loadArchiveData() -> Data? { + guard case let .data(data) = self.loadArchiveResult() else { return nil } + return data + } + + static func loadArchiveResult() -> CookieArchiveLoadResult { #if DEBUG if self.usesDebugFileStorage { - return self.loadArchiveDataFromDebugFile() + return self.loadArchiveResultFromDebugFile() } #endif @@ -264,48 +388,46 @@ enum KeychainCookieStorage { let status = SecItemCopyMatching(query as CFDictionary, &result) guard status == errSecSuccess else { - Self.writeCoordinator.seedPersistedArchive(nil) if status == errSecItemNotFound { + Self.writeCoordinator.seedPersistedArchive(nil) Self.logger.info("No cookies found in Keychain (first run or signed out)") + return .notFound } else { Self.logger.error("Failed to load cookies from Keychain: \(status)") + return .failure } - return nil } guard let data = result as? Data else { - Self.writeCoordinator.seedPersistedArchive(nil) Self.logger.error("Loaded Keychain cookie item had an unexpected type") - return nil + return .failure } Self.writeCoordinator.seedPersistedArchive(data) - return data + return .data(data) } #if DEBUG - private static func loadArchiveDataFromDebugFile() -> Data? { + private static func loadArchiveResultFromDebugFile() -> CookieArchiveLoadResult { guard let fileURL = self.debugCookieFileURL else { self.logger.error("Debug cookie file storage is unavailable") - self.writeCoordinator.seedPersistedArchive(nil) - return nil + return .failure } guard FileManager.default.fileExists(atPath: fileURL.path) else { Self.logger.info("No debug cookies file found") self.writeCoordinator.seedPersistedArchive(nil) - return nil + return .notFound } guard let data = try? Data(contentsOf: fileURL) else { Self.logger.error("Failed to load debug cookies file") - self.writeCoordinator.seedPersistedArchive(nil) - return nil + return .failure } self.writeCoordinator.seedPersistedArchive(data) Self.logger.info("Loaded cookies from debug file storage") - return data + return .data(data) } #endif @@ -341,6 +463,14 @@ enum KeychainCookieStorage { return cookies } + static func isRestorableArchiveData(_ archiveData: Data) -> Bool { + let cookies = self.decodeCookies(from: archiveData) + guard case .snapshot = CookieArchiveVerificationState.make(from: cookies) else { + return false + } + return true + } + /// Retrieves YouTube auth cookies from the active cookie backup store. /// Returns the cookies if found, nil otherwise. static func loadCookies() -> [HTTPCookie]? { @@ -350,17 +480,18 @@ enum KeychainCookieStorage { } /// Deletes cookies from the active cookie backup store. - static func deleteCookies() { + /// Returns true when no persisted archive remains in the active store. + @discardableResult + static func deleteCookies() -> Bool { #if DEBUG if self.usesDebugFileStorage { - self.deleteDebugCookieFile() - return + return self.deleteDebugCookieFile() } // When explicitly testing the Keychain path in DEBUG, also clear // the default debug file so switching backends cannot resurrect a // session the developer just signed out from. - self.deleteDebugCookieFile() + let didDeleteDebugFile = self.deleteDebugCookieFile() #endif let query: [String: Any] = [ @@ -370,21 +501,30 @@ enum KeychainCookieStorage { ] let status = SecItemDelete(query as CFDictionary) - Self.writeCoordinator.seedPersistedArchive(nil) + let didDeleteKeychainItem = status == errSecSuccess || status == errSecItemNotFound + if didDeleteKeychainItem { + Self.writeCoordinator.seedPersistedArchive(nil) + } if status == errSecSuccess { Self.logger.info("Deleted cookies from Keychain") } else if status != errSecItemNotFound { Self.logger.error("Failed to delete cookies from Keychain: \(status)") } + + #if DEBUG + return didDeleteKeychainItem && didDeleteDebugFile + #else + return didDeleteKeychainItem + #endif } #if DEBUG - private static func deleteDebugCookieFile() { + @discardableResult + private static func deleteDebugCookieFile() -> Bool { guard let fileURL = self.debugCookieFileURL else { self.logger.error("Debug cookie file storage is unavailable") - self.writeCoordinator.seedPersistedArchive(nil) - return + return false } do { @@ -392,11 +532,116 @@ enum KeychainCookieStorage { try FileManager.default.removeItem(at: fileURL) Self.logger.info("Deleted cookies from debug file storage") } + self.writeCoordinator.seedPersistedArchive(nil) + return true } catch { Self.logger.warning("Failed to delete debug cookies file: \(error.localizedDescription)") + return false + } + } + #endif +} + +// MARK: - CookieRestorePolicyStorage + +enum CookieRestorePolicyStorage { + private static let logger = DiagnosticsLogger.webKit + private static let keychainAccount = "youtube-music-cookies-restore-policy" + + static func loadResult() -> CookieRestorePolicyLoadResult { + #if DEBUG + if KeychainCookieStorage.usesDebugFileStorage { + guard let url = self.debugFileURL else { return .failure } + guard FileManager.default.fileExists(atPath: url.path) else { return .notFound } + do { + return try self.decode(Data(contentsOf: url)) + } catch { + self.logger.error("Failed to read cookie restore policy: \(error.localizedDescription)") + return .failure + } + } + #endif + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: KeychainCookieStorage.service, + kSecAttrAccount as String: self.keychainAccount, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess else { + if status == errSecItemNotFound { + return .notFound + } + self.logger.error("Failed to read cookie restore policy from Keychain: \(status)") + return .failure + } + guard let data = result as? Data else { return .failure } + return self.decode(data) + } + + static func decode(_ data: Data) -> CookieRestorePolicyLoadResult { + guard data.count == 1, let byte = data.first else { return .failure } + return switch byte { + case 0: + .denied + case 1: + .allowed + default: + .failure + } + } + + @discardableResult + static func save(_ allowed: Bool) -> Bool { + let data = Data([allowed ? 1 : 0]) + #if DEBUG + if KeychainCookieStorage.usesDebugFileStorage { + guard let url = self.debugFileURL else { return false } + do { + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try data.write(to: url, options: .atomic) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: url.path + ) + return true + } catch { + self.logger.error("Failed to persist cookie restore policy: \(error.localizedDescription)") + return false + } + } + #endif + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: KeychainCookieStorage.service, + kSecAttrAccount as String: self.keychainAccount, + ] + let attributes: [String: Any] = [ + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked, + ] + var status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + if status == errSecItemNotFound { + var insertion = query + for (key, value) in attributes { + insertion[key] = value } + status = SecItemAdd(insertion as CFDictionary, nil) + } + return status == errSecSuccess + } - self.writeCoordinator.seedPersistedArchive(nil) + #if DEBUG + private static var debugFileURL: URL? { + KeychainCookieStorage.debugCookieFileURL? + .appendingPathExtension("restore-policy") } #endif } @@ -446,7 +691,7 @@ enum LegacyCookieMigration { else { self.logger.error("Failed to read legacy cookie file for migration") // Delete corrupted file - Self.deleteLegacyFile() + _ = Self.deleteLegacyFileIfPresent() return false } @@ -473,7 +718,7 @@ enum LegacyCookieMigration { guard !validCookies.isEmpty else { self.logger.info("Legacy file contained no valid cookies") #if !DEBUG - Self.deleteLegacyFile() + _ = Self.deleteLegacyFileIfPresent() #endif return false } @@ -491,20 +736,24 @@ enum LegacyCookieMigration { self.logger.info("✓ Successfully migrated \(validCookies.count) cookies to Keychain") #if !DEBUG - Self.deleteLegacyFile() + _ = Self.deleteLegacyFileIfPresent() #endif return true } - /// Deletes the legacy cookie file. - private static func deleteLegacyFile() { - guard let fileURL = legacyFileURL else { return } + /// Deletes the legacy cookie file when present. + @discardableResult + static func deleteLegacyFileIfPresent() -> Bool { + guard let fileURL = legacyFileURL else { return false } + guard FileManager.default.fileExists(atPath: fileURL.path) else { return true } do { try FileManager.default.removeItem(at: fileURL) self.logger.info("Deleted legacy cookie file") + return !FileManager.default.fileExists(atPath: fileURL.path) } catch { self.logger.warning("Failed to delete legacy cookie file: \(error.localizedDescription)") + return false } } } @@ -542,6 +791,20 @@ enum LegacyCookieMigration { return appFolder.appendingPathComponent("cookies.dat") } + @discardableResult + static func deleteExport() -> Bool { + guard let destinationURL = fileURL else { return false } + do { + if FileManager.default.fileExists(atPath: destinationURL.path) { + try FileManager.default.removeItem(at: destinationURL) + } + return true + } catch { + Self.logger.warning("Failed to delete cookies.dat debug export: \(error.localizedDescription)") + return false + } + } + static func exportAuthCookiesArchiveData(_ archiveData: Data) { guard let destinationURL = fileURL else { return } diff --git a/Sources/Kaset/Services/WebKit/WebKitManager.swift b/Sources/Kaset/Services/WebKit/WebKitManager.swift index 8c3b98f86..1265cb1cc 100644 --- a/Sources/Kaset/Services/WebKit/WebKitManager.swift +++ b/Sources/Kaset/Services/WebKit/WebKitManager.swift @@ -3,6 +3,65 @@ import os import Security import WebKit +// MARK: - AuthCookieOperationFence + +struct AuthCookieOperationFence { + private(set) var generation: UInt64 = 0 + + mutating func invalidate() { + self.generation &+= 1 + } + + func isCurrent(_ expectedGeneration: UInt64) -> Bool { + expectedGeneration == self.generation + } +} + +// MARK: - LiveAuthCookieClearResult + +struct LiveAuthCookieClearResult: Equatable { + let didClear: Bool + let usedCookieStoreFallback: Bool +} + +// MARK: - LiveAuthCookieStoreClearer + +@MainActor +enum LiveAuthCookieStoreClearer { + struct Operations { + let readCookies: @MainActor () async -> [HTTPCookie] + let deleteCookie: @MainActor (HTTPCookie) async -> Void + let removeAllCookies: @MainActor () async -> Void + } + + static func clear( + maximumDeletePasses: Int = 3, + operations: Operations + ) async -> LiveAuthCookieClearResult { + for _ in 0 ..< max(maximumDeletePasses, 0) { + let cookies = await operations.readCookies() + for cookie in cookies where KeychainCookieStorage.isLoginSessionCookie(cookie) { + await operations.deleteCookie(cookie) + } + let remainingCookies = await operations.readCookies() + if !remainingCookies.contains(where: KeychainCookieStorage.isLoginSessionCookie) { + return LiveAuthCookieClearResult( + didClear: true, + usedCookieStoreFallback: false + ) + } + await Task.yield() + } + + await operations.removeAllCookies() + let remainingCookies = await operations.readCookies() + return LiveAuthCookieClearResult( + didClear: !remainingCookies.contains(where: KeychainCookieStorage.isLoginSessionCookie), + usedCookieStoreFallback: true + ) + } +} + // MARK: - WebKitManager /// Manages WebKit data store for persistent cookies and session management. @@ -23,17 +82,48 @@ final class WebKitManager: NSObject, WebKitManagerProtocol { /// Timestamp of the last cookie change (for observation). private(set) var cookiesDidChange: Date = .distantPast + func recordCookieChange() { + self.cookiesDidChange = Date() + } + /// Flag to prevent cookie backups while restoring from Keychain. - private var isRestoringCookies = false + var isRestoringCookies = false + + /// Flag to prevent observer-driven backups while auth cookies are being cleared. + var isClearingAuthCookies = false + + /// Serializes and coalesces every auth-cookie/data clearing operation. + let authCookieClearCoordinator = AuthCookieClearCoordinator() /// Task for debouncing cookie change handling. - private var cookieDebounceTask: Task? + var cookieDebounceTask: Task? + + /// Coalesces callers that require the latest cookie snapshot to be persisted. + var forcedCookieBackupTask: Task? + + /// Suppresses observer-driven persistence while a login baseline is captured. + var isPreparingLoginCookieBackup = false + + /// Suppresses observer-driven persistence while a login backup is staged. + var activeLoginCookieBackupTransaction: CookieBackupTransaction? + + /// Whether transaction setup failed to restore its prior durable state. + var loginCookieBackupSetupRequiresCleanup = false + + /// Records cookie changes delivered while a forced snapshot is being persisted. + var forcedCookieBackupDirty = false /// Task for the one-time startup restore from Keychain into WebKit. - private var initialCookieRestoreTask: Task? + private var initialCookieRestoreTask: Task? + + /// Whether startup left authentication cookies safe to evaluate. + private var initialCookieRestoreAllowsAuthentication = true + + /// Invalidates startup restores and backups that began before an auth-cookie clear. + var authCookieOperationFence = AuthCookieOperationFence() /// Minimum interval between cookie backup operations (in seconds). - private static let cookieDebounceInterval: Duration = .seconds(5) + static let cookieDebounceInterval: Duration = .seconds(5) /// The YouTube Music origin URL. static let origin = "https://music.youtube.com" @@ -50,7 +140,7 @@ final class WebKitManager: NSObject, WebKitManagerProtocol { /// Custom user agent to appear as Safari to avoid "browser not supported" errors. static let userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15" - private let logger = DiagnosticsLogger.webKit + let logger = DiagnosticsLogger.webKit private var extensionContexts: [String: WKWebExtensionContext] = [:] @@ -74,9 +164,14 @@ final class WebKitManager: NSObject, WebKitManagerProtocol { // Restore auth cookies on startup. // Keychain is the source of truth; in DEBUG builds we also export to cookies.dat for tooling. if restoresCookies, !UITestConfig.isRunningUnitTests { + let restoreGeneration = self.authCookieOperationFence.generation self.initialCookieRestoreTask = Task { @MainActor in - await self.restoreAuthCookiesFromBackup() + let allowsAuthentication = await self.restoreAuthCookiesFromBackup( + expectedGeneration: restoreGeneration + ) + self.initialCookieRestoreAllowsAuthentication = allowsAuthentication self.initialCookieRestoreTask = nil + return allowsAuthentication } } @@ -123,63 +218,6 @@ final class WebKitManager: NSObject, WebKitManagerProtocol { return nil } - /// Restores auth cookies from Keychain to WebKit. - /// Handles migration from legacy file-based storage on first run. - private func restoreAuthCookiesFromBackup() async { - self.isRestoringCookies = true - defer { isRestoringCookies = false } - - // Wait a moment for WebKit to fully initialize - try? await Task.sleep(for: .milliseconds(100)) - - // Migrate from legacy file-based storage if needed (one-time operation). - // Perform file I/O off the main actor. - _ = await Task(priority: .utility) { - LegacyCookieMigration.migrateIfNeeded() - }.value - - let existingCookies = await dataStore.httpCookieStore.allCookies() - self.logger.info("WebKit has \(existingCookies.count) cookies on startup") - - // Load cookies from Keychain. - // Perform Keychain I/O off the main actor; decode on main actor. - let archiveData = await Task(priority: .utility) { - KeychainCookieStorage.loadArchiveData() - }.value - - guard let archiveData else { - self.logger.info("No cookies found in Keychain (first run or signed out)") - return - } - - let keychainCookies = KeychainCookieStorage.decodeCookies(from: archiveData) - guard !keychainCookies.isEmpty else { - self.logger.info("No valid cookies found in Keychain") - return - } - - #if DEBUG - DebugCookieFileExporter.exportAuthCookiesArchiveData(archiveData) - #endif - - self.logger.info("Restoring \(keychainCookies.count) auth cookies from Keychain") - - // Set each cookie in WebKit - for cookie in keychainCookies { - await self.dataStore.httpCookieStore.setCookie(cookie) - } - - // Verify restore - let cookies = await dataStore.httpCookieStore.allCookies() - let hasAuth = cookies.contains { $0.name == "SAPISID" || $0.name == "__Secure-3PAPISID" } - - if hasAuth { - self.logger.info("✓ Auth cookies restored from Keychain (\(cookies.count) total cookies)") - } else { - self.logger.error("✗ Failed to restore auth cookies - Keychain data may be corrupted") - } - } - /// Loads all enabled extensions from `ExtensionsManager`. private func loadExtensions() async { #if compiler(>=5.9) @@ -419,10 +457,11 @@ final class WebKitManager: NSObject, WebKitManagerProtocol { } /// Waits for the one-time startup cookie restore to finish. - func waitForInitialCookieRestore() async { + func waitForInitialCookieRestore() async -> Bool { if let restoreTask = self.initialCookieRestoreTask { - await restoreTask.value + return await restoreTask.value } + return self.initialCookieRestoreAllowsAuthentication } /// Retrieves all cookies from the HTTP cookie store. @@ -519,106 +558,107 @@ final class WebKitManager: NSObject, WebKitManagerProtocol { } /// Clears only authentication cookies, preserving public WebKit cache/data. - func clearAuthCookies() async { + @discardableResult + func clearAuthCookies() async -> Bool { + await self.authCookieClearCoordinator.run(scope: .authenticationCookies) { [weak self] in + guard let self else { return false } + return await self.performAuthCookieClear() + } + } + + private func performAuthCookieClear() async -> Bool { self.logger.info("Clearing WebKit auth cookies") - let cookies = await self.dataStore.httpCookieStore.allCookies() - for cookie in cookies where KeychainCookieStorage.authCookieNames.contains(cookie.name) { - await self.dataStore.httpCookieStore.deleteCookie(cookie) + self.isClearingAuthCookies = true + defer { self.isClearingAuthCookies = false } + + await self.fenceAuthCookieOperationsAndInvalidateBackup() + + // Invalidate once more after the initial fence so no operation that was + // already queued before it can leave a durable stale snapshot. All live + // verification happens after this final persistence suspension. + let didInvalidatePersistedCookies = await CookieArchiveWriteQueue.shared.invalidateAndDelete() + + let liveClearResult = await LiveAuthCookieStoreClearer.clear( + operations: LiveAuthCookieStoreClearer.Operations( + readCookies: { + await self.dataStore.httpCookieStore.allCookies() + }, + deleteCookie: { cookie in + await self.dataStore.httpCookieStore.deleteCookie(cookie) + }, + removeAllCookies: { + await self.dataStore.removeData( + ofTypes: [WKWebsiteDataTypeCookies], + modifiedSince: .distantPast + ) + } + ) + ) + if liveClearResult.usedCookieStoreFallback { + self.logger.warning("Escalated authentication cleanup to the WebKit cookie data store") } - KeychainCookieStorage.deleteCookies() + if !liveClearResult.didClear { + self.logger.error("Could not clear live authentication cookies") + } + + let didClear = liveClearResult.didClear && didInvalidatePersistedCookies + self.initialCookieRestoreAllowsAuthentication = didClear + self.loginCookieBackupSetupRequiresCleanup = !didClear self.cookiesDidChange = Date() + return didClear } /// Clears all website data (cookies, cache, etc.). - func clearAllData() async { + @discardableResult + func clearAllData() async -> Bool { + await self.authCookieClearCoordinator.run(scope: .allWebsiteData) { [weak self] in + guard let self else { return false } + return await self.performAllWebsiteDataClear() + } + } + + private func performAllWebsiteDataClear() async -> Bool { let allTypes = WKWebsiteDataStore.allWebsiteDataTypes() let dateFrom = Date.distantPast self.logger.info("Clearing all WebKit data") + self.isClearingAuthCookies = true + defer { self.isClearingAuthCookies = false } + await self.fenceAuthCookieOperationsAndInvalidateBackup() await self.dataStore.removeData(ofTypes: allTypes, modifiedSince: dateFrom) - // Also clear cookies from Keychain - KeychainCookieStorage.deleteCookies() - - self.logger.info("WebKit data cleared successfully") - } - - /// Forces an immediate save of all YouTube/Google cookies to Keychain. - /// Call this after successful login to ensure cookies are persisted. - func forceBackupCookies() async { - let cookies = await dataStore.httpCookieStore.allCookies() - self.logger.info("Force backup: found \(cookies.count) total cookies") - - // Filter for YouTube/Google auth cookies - let authCookies = cookies.filter { cookie in - let domain = cookie.domain.lowercased() - return domain.hasSuffix("youtube.com") || domain.hasSuffix("google.com") - } - - self.logger.info("Force backup: \(authCookies.count) YouTube/Google cookies to Keychain") - guard let archive = KeychainCookieStorage.makeArchiveData(from: authCookies) else { return } - - // Perform Keychain/file I/O off the main actor. - // Fire-and-forget: failures are handled inside KeychainCookieStorage. - Task(priority: .utility) { - _ = KeychainCookieStorage.saveArchiveData(archive.data, cookieCount: archive.cookieCount) - #if DEBUG - DebugCookieFileExporter.exportAuthCookiesArchiveData(archive.data) - #endif - } - } -} - -// MARK: WKHTTPCookieStoreObserver - -extension WebKitManager: WKHTTPCookieStoreObserver { - nonisolated func cookiesDidChange(in cookieStore: WKHTTPCookieStore) { - Task { @MainActor in - self.cookiesDidChange = Date() - - guard !self.isRestoringCookies else { return } - - // Debounce cookie backup to avoid excessive writes - // WebKit fires this callback for each individual cookie change, - // which can result in dozens of calls in rapid succession - self.cookieDebounceTask?.cancel() - self.cookieDebounceTask = Task { - do { - try await Task.sleep(for: Self.cookieDebounceInterval) - } catch is CancellationError { - // Task was cancelled (new cookie change came in), skip backup - return - } catch { - // Unexpected error during sleep - log and continue with backup - self.logger.warning("Unexpected error during cookie debounce: \(error.localizedDescription)") - } - - // Perform debounced backup - await self.performCookieBackup(cookieStore: cookieStore) - } + // Repeat the invalidation after WebKit finishes clearing to close the + // window for any already-enqueued observer or backup work. + let didInvalidatePersistedCookies = await CookieArchiveWriteQueue.shared.invalidateAndDelete() + let remainingCookies = await self.dataStore.httpCookieStore.allCookies() + let didClearLiveCookies = !remainingCookies.contains(where: KeychainCookieStorage.isLoginDomainCookie) + let didClear = didInvalidatePersistedCookies && didClearLiveCookies + self.initialCookieRestoreAllowsAuthentication = didClear + self.loginCookieBackupSetupRequiresCleanup = !didClear + if didClear { + self.logger.info("WebKit data cleared successfully") + } else { + self.logger.error("WebKit data or durable cookie invalidation could not be cleared") } + self.cookiesDidChange = Date() + return didClear } - /// Performs the actual cookie backup after debouncing. - private func performCookieBackup(cookieStore: WKHTTPCookieStore) async { - let cookies = await cookieStore.allCookies() + private func fenceAuthCookieOperationsAndInvalidateBackup() async { + self.authCookieOperationFence.invalidate() + self.activeLoginCookieBackupTransaction = nil + self.cookieDebounceTask?.cancel() + self.cookieDebounceTask = nil - // Filter for YouTube/Google auth cookies - let authCookies = cookies.filter { cookie in - let domain = cookie.domain.lowercased() - return domain.hasSuffix("youtube.com") || domain.hasSuffix("google.com") - } + let restoreTask = self.initialCookieRestoreTask + let backupTask = self.forcedCookieBackupTask + restoreTask?.cancel() + backupTask?.cancel() - guard let archive = KeychainCookieStorage.makeArchiveData(from: authCookies) else { return } - - // Perform Keychain/file I/O off the main thread. - Task.detached(priority: .utility) { - _ = KeychainCookieStorage.saveArchiveData(archive.data, cookieCount: archive.cookieCount) - #if DEBUG - DebugCookieFileExporter.exportAuthCookiesArchiveData(archive.data) - #endif - } + _ = await CookieArchiveWriteQueue.shared.invalidateAndDelete() + _ = await restoreTask?.value + _ = await backupTask?.value } } diff --git a/Sources/Kaset/Views/GeneralSettingsView.swift b/Sources/Kaset/Views/GeneralSettingsView.swift index 41a8d321a..6838ce62b 100644 --- a/Sources/Kaset/Views/GeneralSettingsView.swift +++ b/Sources/Kaset/Views/GeneralSettingsView.swift @@ -6,6 +6,7 @@ struct GeneralSettingsView: View { @State private var settings = SettingsManager.shared @State private var cacheSize: String = .init(localized: "Calculating...") @State private var isClearing = false + @State private var signOutFailurePresented = false /// The updater service for managing app updates. var updaterService: UpdaterService @@ -30,7 +31,9 @@ struct GeneralSettingsView: View { if self.authService.state.isLoggedIn { Button(String(localized: "Sign Out")) { Task { - await self.authService.signOut() + if await self.authService.signOut() == false { + self.signOutFailurePresented = true + } } } } @@ -163,6 +166,22 @@ struct GeneralSettingsView: View { } } .formStyle(.grouped) + .alert( + String(localized: "Sign Out Incomplete"), + isPresented: self.$signOutFailurePresented + ) { + Button(String(localized: "Retry")) { + Task { + self.signOutFailurePresented = await self.authService.signOut() == false + } + } + Button(String(localized: "OK"), role: .cancel) {} + } message: { + Text( + "Kaset could not remove saved sign-in data. Try signing out again before quitting.", + comment: "Sign-out durable storage failure message" + ) + } .frame(minWidth: 400, minHeight: 300) .localizedNavigationTitle("General") .task { diff --git a/Sources/Kaset/Views/LoginSheet.swift b/Sources/Kaset/Views/LoginSheet.swift index ba1bcc0ae..d0d8dbbee 100644 --- a/Sources/Kaset/Views/LoginSheet.swift +++ b/Sources/Kaset/Views/LoginSheet.swift @@ -1,5 +1,7 @@ import SwiftUI +// MARK: - LoginSheet + /// Login sheet presented when authentication is required. struct LoginSheet: View { @Environment(AuthService.self) private var authService @@ -8,10 +10,15 @@ struct LoginSheet: View { @Environment(\.dismiss) private var dismiss @State private var isCheckingLogin = false - @State private var didCaptureInitialSAPISID = false + @State private var didCaptureInitialLoginState = false @State private var didCompleteLogin = false - @State private var initialSAPISID: String? + @State private var loginAttemptID: LoginAttemptID? + @State private var cookieBackupTransaction: CookieBackupTransaction? + @State private var preparationTask: Task? @State private var pollTask: Task? + @State private var loginCheckTask: Task? + @State private var cleanupRetryTask: Task? + @State private var isRetryingCleanup = false @State private var isActive = false var body: some View { @@ -23,7 +30,9 @@ struct LoginSheet: View { // Do not create the login WebView until reauthentication has drained // old session mutations and established its cookie baseline. - if self.didCaptureInitialSAPISID { + if self.authService.loginCleanupRequired { + self.loginCleanupFailureView + } else if self.didCaptureInitialLoginState { LoginWebView(onNavigationToYouTubeMusic: { self.checkForSuccessfulLogin() }) @@ -39,28 +48,122 @@ struct LoginSheet: View { } .onAppear { self.isActive = true + if self.authService.activeLoginAttemptID == nil, + self.authService.needsReauth, + !self.authService.loginCleanupRequired + { + self.authService.startLogin() + } + self.loginAttemptID = self.authService.activeLoginAttemptID } .task { - self.isActive = true - if self.authService.needsReauth { - await self.accountService.prepareForReauthentication() - guard !Task.isCancelled, self.isActive, self.authService.needsReauth else { return } - await self.webKitManager.clearAuthCookies() - } - self.initialSAPISID = await self.webKitManager.getSAPISID() guard !Task.isCancelled, self.isActive else { return } - self.didCaptureInitialSAPISID = true - self.startPollingForLogin() + if let preparationTask = self.preparationTask { + await preparationTask.value + return + } + + let preparationTask = Task { @MainActor in + await self.prepareLoginAttempt() + } + self.preparationTask = preparationTask + await preparationTask.value + self.preparationTask = nil } .onDisappear { self.isActive = false - self.pollTask?.cancel() - if !self.didCompleteLogin { - self.authService.cancelLoginIfNeeded() + guard !self.didCompleteLogin else { return } + + let loginAttemptID = self.loginAttemptID + if let transaction = self.cookieBackupTransaction, + !transaction.revokeCommit() + { + self.authService.setLoginCleanupRequired(true) + } + let didBeginCancellation = loginAttemptID.map { + self.authService.beginLoginCancellation(expectedAttemptID: $0) + } ?? false + let preparationTask = self.preparationTask + let pollTask = self.pollTask + let loginCheckTask = self.loginCheckTask + let cleanupRetryTask = self.cleanupRetryTask + preparationTask?.cancel() + pollTask?.cancel() + loginCheckTask?.cancel() + cleanupRetryTask?.cancel() + + Task { @MainActor in + await preparationTask?.value + await loginCheckTask?.value + await pollTask?.value + await cleanupRetryTask?.value + + if let transaction = self.cookieBackupTransaction { + self.cookieBackupTransaction = nil + _ = await self.authService.resolveLoginRollbackAfterDraining( + expectedAttemptID: loginAttemptID, + prepareRollback: { + await self.webKitManager.prepareLoginCookieBackupRollback(transaction) + }, + rollback: { forceCleanup in + await rollbackLoginCookiesWithFallback( + forceCleanup: forceCleanup, + authService: self.authService, + webKitManager: self.webKitManager, + transaction: transaction, + expectedAttemptID: loginAttemptID + ) + } + ) + } else if didBeginCancellation { + _ = await self.authService.resolveLoginRollbackAfterDraining( + expectedAttemptID: loginAttemptID, + prepareRollback: { true }, + // No transaction means no cookie state was restored. A + // prior durable-cleanup failure must remain quarantined. + rollback: { forceCleanup in + forceCleanup || self.authService.loginCleanupRequired ? .failed : .rolledBack + } + ) + } } } } + private var loginCleanupFailureView: some View { + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 32)) + .foregroundStyle(.orange) + .accessibilityHidden(true) + + Text(String(localized: "Sign-In Cleanup Required")) + .font(.headline) + + Text( + "Kaset could not safely clear saved sign-in data. Retry before signing in again.", + comment: "Failed login cleanup explanation" + ) + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + .frame(maxWidth: 340) + + Button { + self.retryLoginCleanup() + } label: { + if self.isRetryingCleanup || self.authService.isLoginCleanupInProgress { + ProgressView() + .controlSize(.small) + } else { + Text(String(localized: "Retry")) + } + } + .disabled(self.isRetryingCleanup || self.authService.isLoginCleanupInProgress) + } + .padding(32) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + private var headerView: some View { VStack(alignment: .leading, spacing: 4) { HStack { @@ -84,6 +187,143 @@ struct LoginSheet: View { .padding() } + private func prepareLoginAttempt() async { + guard !self.authService.loginCleanupRequired, + let loginAttemptID = self.loginAttemptID + else { return } + + if self.authService.needsReauth { + await self.accountService.prepareForReauthentication() + guard !Task.isCancelled, self.isActive, self.authService.needsReauth else { return } + // Routine reauthentication preserves public WebKit storage; only + // rollback/cleanup failures escalate to a full data-store clear. + let didClear = await self.webKitManager.clearAuthCookies() + guard self.authService.activeLoginAttemptID == loginAttemptID else { return } + guard didClear else { + _ = await self.authService.clearFailedLoginAfterDraining( + expectedAttemptID: loginAttemptID, + expectedSignOutSequence: self.authService.signOutSequence, + clearCookies: { false } + ) + return + } + self.authService.setLoginCleanupRequired(false) + } + + let canEvaluateAuthentication = await self.webKitManager.waitForInitialCookieRestore() + guard self.authService.activeLoginAttemptID == loginAttemptID else { return } + guard canEvaluateAuthentication else { + let didClear = await self.authService.clearFailedLoginAfterDraining( + expectedAttemptID: loginAttemptID, + expectedSignOutSequence: self.authService.signOutSequence, + clearCookies: { + await self.webKitManager.clearAllData() + } + ) + if didClear == true { + await self.restartLoginAfterCleanup() + } + return + } + + guard !Task.isCancelled, + self.isActive, + self.authService.activeLoginAttemptID == loginAttemptID + else { + self.cancelAndDismissIfCurrent(loginAttemptID) + return + } + + let transaction = await self.webKitManager.beginLoginCookieBackup() + if transaction == nil, self.webKitManager.loginCookieBackupSetupRequiresCleanup { + let didClear = await clearFailedLogin( + authService: self.authService, + webKitManager: self.webKitManager, + expectedAttemptID: loginAttemptID + ) + if didClear == true { + self.dismiss() + } + return + } + guard let transaction, + !Task.isCancelled, + self.isActive, + self.authService.activeLoginAttemptID == loginAttemptID + else { + guard let transaction else { + self.cancelAndDismissIfCurrent(loginAttemptID) + return + } + let rollbackResult = await self.authService.resolveLoginRollbackAfterDraining( + expectedAttemptID: loginAttemptID, + prepareRollback: { + await self.webKitManager.prepareLoginCookieBackupRollback(transaction) + }, + rollback: { forceCleanup in + await rollbackLoginCookiesWithFallback( + forceCleanup: forceCleanup, + authService: self.authService, + webKitManager: self.webKitManager, + transaction: transaction, + expectedAttemptID: loginAttemptID + ) + } + ) + if rollbackResult == .rolledBack || rollbackResult == .cleared { + self.dismiss() + } + return + } + self.cookieBackupTransaction = transaction + self.didCaptureInitialLoginState = true + self.startPollingForLogin() + } + + private func retryLoginCleanup() { + guard self.cleanupRetryTask == nil, + !self.authService.isLoginCleanupInProgress + else { return } + let expectedAttemptID = self.authService.activeLoginAttemptID + ?? self.loginAttemptID + ?? LoginAttemptID(rawValue: 0) + let expectedSignOutSequence = self.authService.signOutSequence + self.cleanupRetryTask = Task { @MainActor in + self.isRetryingCleanup = true + defer { + self.isRetryingCleanup = false + self.cleanupRetryTask = nil + } + + let didClear = await self.authService.clearFailedLoginAfterDraining( + expectedAttemptID: expectedAttemptID, + expectedSignOutSequence: expectedSignOutSequence, + clearCookies: { + await self.webKitManager.clearAllData() + } + ) + guard !Task.isCancelled, + self.isActive, + let didClear + else { return } + guard didClear else { return } + + await self.restartLoginAfterCleanup() + } + } + + private func restartLoginAfterCleanup() async { + guard self.isActive else { return } + if self.authService.activeLoginAttemptID == nil { + self.authService.startLogin() + } + self.loginAttemptID = self.authService.activeLoginAttemptID + self.didCaptureInitialLoginState = false + self.didCompleteLogin = false + self.cookieBackupTransaction = nil + await self.prepareLoginAttempt() + } + /// Starts a periodic task to check for successful login. private func startPollingForLogin() { guard self.isActive else { return } @@ -100,49 +340,192 @@ struct LoginSheet: View { private func checkForSuccessfulLogin() { guard self.isActive else { return } - guard !self.isCheckingLogin else { return } + guard !self.isCheckingLogin, self.loginCheckTask == nil else { return } - Task { + self.loginCheckTask = Task { await self.checkForSuccessfulLoginAsync() + self.loginCheckTask = nil } } private func checkForSuccessfulLoginAsync() async { - guard !self.isCheckingLogin else { return } - - guard self.isActive else { return } - - guard self.didCaptureInitialSAPISID else { return } + guard !self.isCheckingLogin, + self.isActive, + self.didCaptureInitialLoginState, + let loginAttemptID = self.loginAttemptID, + let transaction = self.cookieBackupTransaction + else { return } self.isCheckingLogin = true + defer { self.isCheckingLogin = false } - // Small delay to allow cookies to settle + // Small delay to allow cookies to settle. try? await Task.sleep(for: .milliseconds(300)) - guard !Task.isCancelled, self.isActive else { - self.isCheckingLogin = false + guard !Task.isCancelled, self.isActive else { return } + + guard await self.webKitManager.getSAPISID() != nil, + await self.webKitManager.hasLoginCookieSnapshotChanged(transaction) + else { return } - if let sapisid = await webKitManager.getSAPISID(), sapisid != self.initialSAPISID { - // Force backup cookies immediately after login - // This ensures persistence across app restarts even if WebKit loses data - await self.webKitManager.forceBackupCookies() + // Allow the rest of the login cookie set to propagate before taking + // the durable snapshot used across app restarts. + try? await Task.sleep(for: .milliseconds(200)) + guard !Task.isCancelled, self.isActive else { return } - // Wait a moment longer to ensure all cookies are fully propagated - // This prevents race conditions where API calls happen before cookies are ready - try? await Task.sleep(for: .milliseconds(200)) - guard !Task.isCancelled, self.isActive else { - self.isCheckingLogin = false - return + let completionGate = LoginCompletionGate( + webKitManager: self.webKitManager, + authService: self.authService + ) + guard await completionGate.complete( + expectedAttemptID: loginAttemptID, + transaction: transaction, + willPublishLogin: { + self.didCompleteLogin = true } - - self.didCompleteLogin = true - await self.authService.completeLoginAfterDraining(sapisid: sapisid) + ) else { self.pollTask?.cancel() - self.dismiss() + self.loginCheckTask?.cancel() + self.cookieBackupTransaction = nil + self.didCompleteLogin = false + guard !self.authService.loginCleanupRequired else { return } + if self.authService.activeLoginAttemptID == loginAttemptID { + self.cancelAndDismissIfCurrent(loginAttemptID) + } else if self.authService.activeLoginAttemptID == nil { + self.dismiss() + } + return } + guard !Task.isCancelled else { return } - self.isCheckingLogin = false + self.cookieBackupTransaction = nil + self.pollTask?.cancel() + self.dismiss() + } + + private func cancelAndDismissIfCurrent(_ loginAttemptID: LoginAttemptID) { + guard self.authService.activeLoginAttemptID == loginAttemptID else { return } + self.authService.cancelLoginIfNeeded(expectedAttemptID: loginAttemptID) + self.dismiss() + } +} + +// MARK: - Failed Login Cleanup + +@MainActor +private func rollbackLoginCookiesWithFallback( + forceCleanup: Bool, + authService: any AuthServiceProtocol, + webKitManager: any WebKitManagerProtocol, + transaction: CookieBackupTransaction, + expectedAttemptID: LoginAttemptID? +) async -> CookieBackupRollbackResult { + if forceCleanup { + return await webKitManager.clearAllData() ? .cleared : .failed + } + return switch await webKitManager.rollbackLoginCookieBackup(transaction) { + case .rolledBack: + .rolledBack + case .cleared: + .cleared + case .superseded: + if let expectedAttemptID, + authService.activeLoginAttemptID == expectedAttemptID + { + await webKitManager.clearAllData() ? .cleared : .failed + } else { + .superseded + } + case .failed: + await webKitManager.clearAllData() ? .cleared : .failed + } +} + +@MainActor +private func clearFailedLogin( + authService: any AuthServiceProtocol, + webKitManager: any WebKitManagerProtocol, + expectedAttemptID: LoginAttemptID +) async -> Bool? { + await authService.clearFailedLoginAfterDraining( + expectedAttemptID: expectedAttemptID, + expectedSignOutSequence: authService.signOutSequence, + clearCookies: { + await webKitManager.clearAllData() + } + ) +} + +// MARK: - LoginCompletionGate + +@MainActor +struct LoginCompletionGate { + let webKitManager: any WebKitManagerProtocol + let authService: any AuthServiceProtocol + + func complete( + expectedAttemptID: LoginAttemptID, + transaction: CookieBackupTransaction, + willPublishLogin: @escaping @MainActor @Sendable () -> Void = {} + ) async -> Bool { + guard !Task.isCancelled, + self.authService.activeLoginAttemptID == expectedAttemptID + else { + await self.rollbackOrInvalidate( + transaction, + expectedAttemptID: expectedAttemptID + ) + return false + } + + let didComplete = await self.authService.completeLoginAfterDraining( + expectedAttemptID: expectedAttemptID, + persistBeforeCommit: { + guard !Task.isCancelled, + let committedSessionValue = await self.webKitManager + .commitLoginCookieBackup(transaction) + else { return nil } + return committedSessionValue + }, + persistFinalSession: { + guard !Task.isCancelled, + let finalSessionValue = await self.webKitManager + .finalizeLoginCookieBackup(transaction) + else { return nil } + return finalSessionValue + }, + willPublishLogin: willPublishLogin + ) + guard didComplete else { + await self.rollbackOrInvalidate( + transaction, + expectedAttemptID: expectedAttemptID + ) + return false + } + return true + } + + private func rollbackOrInvalidate( + _ transaction: CookieBackupTransaction, + expectedAttemptID: LoginAttemptID + ) async { + _ = await self.authService.resolveLoginRollbackAfterDraining( + expectedAttemptID: expectedAttemptID, + prepareRollback: { + await self.webKitManager.prepareLoginCookieBackupRollback(transaction) + }, + rollback: { forceCleanup in + await rollbackLoginCookiesWithFallback( + forceCleanup: forceCleanup, + authService: self.authService, + webKitManager: self.webKitManager, + transaction: transaction, + expectedAttemptID: expectedAttemptID + ) + } + ) } } diff --git a/Sources/Kaset/Views/MainWindow.swift b/Sources/Kaset/Views/MainWindow.swift index 723eeefdd..9c92256cd 100644 --- a/Sources/Kaset/Views/MainWindow.swift +++ b/Sources/Kaset/Views/MainWindow.swift @@ -246,6 +246,11 @@ struct MainWindow: View { // swiftlint:disable:this type_body_length self.showLoginSheet = true } } + .onChange(of: self.authService.loginCleanupRequired) { _, cleanupRequired in + guard cleanupRequired else { return } + self.playerService.reloadCurrentTrackForAuthDataStoreChange(usesCookieFreeDataStore: true) + self.youtubePlayerService.reloadCurrentVideoForAuthDataStoreChange(usesCookieFreeDataStore: true) + } .onChange(of: self.playerService.showVideo) { _, showVideo in DiagnosticsLogger.player.debug("showVideo onChange triggered: \(showVideo)") if showVideo { diff --git a/Tests/KasetTests/AccountServiceTests.swift b/Tests/KasetTests/AccountServiceTests.swift index 4827183b3..46758cd53 100644 --- a/Tests/KasetTests/AccountServiceTests.swift +++ b/Tests/KasetTests/AccountServiceTests.swift @@ -887,13 +887,13 @@ struct AccountServiceTests { } let signOutFlow = Task { @MainActor in - await services.auth.signOut() + _ = await services.auth.signOut() } await Task.yield() #expect(mockWebKit.clearAllDataCalled == false) await oldPinGate.open() - await signOutFlow.value + _ = await signOutFlow.value #expect(mockWebKit.clearAllDataCalled == true) #expect(services.auth.state == .loggedOut) diff --git a/Tests/KasetTests/AuthServiceDurabilityTests.swift b/Tests/KasetTests/AuthServiceDurabilityTests.swift new file mode 100644 index 000000000..d4fe7a787 --- /dev/null +++ b/Tests/KasetTests/AuthServiceDurabilityTests.swift @@ -0,0 +1,207 @@ +import Testing +@testable import Kaset + +@Suite("AuthService durability", .serialized, .tags(.service)) +@MainActor +struct AuthServiceDurabilityTests { + @Test("Sign out stops before account drain when the restoration fence cannot persist") + func signOutStopsWhenRestorationFenceFails() async { + let webKitManager = MockWebKitManager() + let authService = AuthService(webKitManager: webKitManager) + authService.completeLogin(sapisid: "test-sapisid") + webKitManager.invalidateAuthCookieRestorationResult = false + let identityGeneration = authService.accountIdentityGeneration + let signOutSequence = authService.signOutSequence + let boundaryBegins = LockedCounter() + let boundaryEnds = LockedCounter() + let drains = LockedCounter() + authService.setAccountBoundaryHandlers( + willBegin: { boundaryBegins.increment() }, + didEnd: { boundaryEnds.increment() }, + drain: { drains.increment() } + ) + + let didSignOutDurably = await authService.signOut() + + #expect(!didSignOutDurably) + #expect(authService.state == .loggedIn(sapisid: "test-sapisid")) + #expect(authService.accountIdentityGeneration == identityGeneration) + #expect(authService.signOutSequence == signOutSequence) + #expect(!webKitManager.clearAllDataCalled) + #expect(boundaryBegins.isEmpty) + #expect(boundaryEnds.isEmpty) + #expect(drains.isEmpty) + } + + @Test("Rollback preparation failure still drains and runs cleanup") + func rollbackPreparationFailureStillDrainsBoundary() async throws { + let authService = AuthService(webKitManager: MockWebKitManager()) + authService.startLogin() + let attemptID = try #require(authService.activeLoginAttemptID) + let drains = LockedCounter() + let rollbackCalls = LockedCounter() + authService.setAccountBoundaryHandlers( + willBegin: {}, + didEnd: {}, + drain: { drains.increment() } + ) + + let result = await authService.resolveLoginRollbackAfterDraining( + expectedAttemptID: attemptID, + prepareRollback: { false }, + rollback: { forceCleanup in + #expect(forceCleanup) + rollbackCalls.increment() + return .cleared + } + ) + + #expect(result == .cleared) + #expect(drains.count == 1) + #expect(rollbackCalls.count == 1) + #expect(authService.state == .loggedOut) + #expect(!authService.loginCleanupRequired) + } + + @Test("Failed-login cleanup persists invalidation before draining") + func failedLoginCleanupRequiresDurableInvalidation() async throws { + let webKitManager = MockWebKitManager() + webKitManager.invalidateAuthCookieRestorationResult = false + let authService = AuthService(webKitManager: webKitManager) + authService.startLogin() + let attemptID = try #require(authService.activeLoginAttemptID) + let drains = LockedCounter() + let clearCalls = LockedCounter() + authService.setAccountBoundaryHandlers( + willBegin: {}, + didEnd: {}, + drain: { drains.increment() } + ) + + let result = await authService.clearFailedLoginAfterDraining( + expectedAttemptID: attemptID, + expectedSignOutSequence: authService.signOutSequence, + clearCookies: { + clearCalls.increment() + return true + } + ) + + #expect(result == false) + #expect(authService.state == .loggingIn) + #expect(authService.loginCleanupRequired) + #expect(authService.shouldPersistGuestPlaybackState) + #expect(drains.isEmpty) + #expect(clearCalls.isEmpty) + #expect(webKitManager.invalidateAuthCookieRestorationCalled) + } + + @Test("Verified rollback clears a transient cleanup latch") + func verifiedRollbackClearsCleanupLatch() async throws { + let authService = AuthService(webKitManager: MockWebKitManager()) + authService.startLogin() + authService.setLoginCleanupRequired(true) + let attemptID = try #require(authService.activeLoginAttemptID) + + let result = await authService.resolveLoginRollbackAfterDraining( + expectedAttemptID: attemptID, + prepareRollback: { true }, + rollback: { forceCleanup in + #expect(!forceCleanup) + return .rolledBack + } + ) + + #expect(result == .rolledBack) + #expect(!authService.loginCleanupRequired) + } + + @Test("Stale residual cleanup cannot claim a later logged-out attempt") + func staleResidualCleanupCannotClaimLaterAttempt() async throws { + let webKitManager = MockWebKitManager() + let authService = AuthService(webKitManager: webKitManager) + authService.sessionExpired() + authService.startLogin() + let staleAttemptID = try #require(authService.activeLoginAttemptID) + let expectedSignOutSequence = authService.signOutSequence + authService.cancelLoginIfNeeded(expectedAttemptID: staleAttemptID) + + authService.startLogin() + let replacementAttemptID = try #require(authService.activeLoginAttemptID) + authService.completeLogin(sapisid: "replacement-session") + authService.sessionExpired() + let identityGeneration = authService.accountIdentityGeneration + let clearCalls = LockedCounter() + + let result = await authService.clearFailedLoginAfterDraining( + expectedAttemptID: staleAttemptID, + expectedSignOutSequence: expectedSignOutSequence, + clearCookies: { + clearCalls.increment() + return true + } + ) + + #expect(replacementAttemptID != staleAttemptID) + #expect(result == nil) + #expect(!webKitManager.invalidateAuthCookieRestorationCalled) + #expect(clearCalls.isEmpty) + #expect(authService.accountIdentityGeneration == identityGeneration) + #expect(authService.state == .loggedOut) + } + + @Test("Reauthentication cleanup preserves account-owned playback state") + func reauthenticationCleanupPreservesAccountOwnership() async throws { + let authService = AuthService(webKitManager: MockWebKitManager()) + authService.completeLogin(sapisid: "existing-session") + authService.startLogin() + let attemptID = try #require(authService.activeLoginAttemptID) + + let result = await authService.resolveLoginRollbackAfterDraining( + expectedAttemptID: attemptID, + prepareRollback: { true }, + rollback: { forceCleanup in + #expect(!forceCleanup) + return .failed + } + ) + + #expect(result == .failed) + #expect(authService.loginCleanupRequired) + #expect(authService.needsReauth) + #expect(!authService.shouldPersistGuestPlaybackState) + } + + @Test("Cleanup in progress blocks a replacement login attempt") + func cleanupInProgressBlocksReplacementLogin() async throws { + let authService = AuthService(webKitManager: MockWebKitManager()) + authService.startLogin() + let attemptID = try #require(authService.activeLoginAttemptID) + let cleanupStarted = AsyncGate() + let releaseCleanup = AsyncGate() + + let cleanup = Task { @MainActor in + await authService.clearFailedLoginAfterDraining( + expectedAttemptID: attemptID, + expectedSignOutSequence: authService.signOutSequence, + clearCookies: { + await cleanupStarted.open() + await releaseCleanup.wait() + return true + } + ) + } + await cleanupStarted.wait() + #expect(authService.loginCleanupRequired) + #expect(authService.isLoginCleanupInProgress) + + authService.startLogin() + + #expect(authService.activeLoginAttemptID == nil) + #expect(authService.state == .loggedOut) + await releaseCleanup.open() + #expect(await cleanup.value == true) + #expect(!authService.loginCleanupRequired) + #expect(!authService.isLoginCleanupInProgress) + } +} diff --git a/Tests/KasetTests/AuthServiceTests.swift b/Tests/KasetTests/AuthServiceTests.swift index ae795349c..2651bb12f 100644 --- a/Tests/KasetTests/AuthServiceTests.swift +++ b/Tests/KasetTests/AuthServiceTests.swift @@ -38,6 +38,17 @@ struct AuthServiceTests { #expect(self.authService.state == .loggingIn) } + @Test("Repeated start login keeps the active attempt stable") + func repeatedStartLoginKeepsAttemptStable() { + self.authService.startLogin() + let attemptID = self.authService.activeLoginAttemptID + + self.authService.startLogin() + + #expect(self.authService.activeLoginAttemptID == attemptID) + #expect(self.authService.state == .loggingIn) + } + @Test("Cancel login restores prior logged-in session") func cancelLoginRestoresLoggedInState() { self.authService.completeLogin(sapisid: "existing-sapisid") @@ -48,6 +59,24 @@ struct AuthServiceTests { #expect(self.authService.state == .loggedIn(sapisid: "existing-sapisid")) } + @Test("Stale sheet cancellation cannot cancel a replacement login attempt") + func staleCancellationCannotCancelReplacementAttempt() async { + await self.authService.checkLoginStatus() + self.authService.startLogin() + guard let staleAttemptID = self.authService.activeLoginAttemptID else { + Issue.record("Expected an active login attempt") + return + } + self.authService.cancelLoginIfNeeded(expectedAttemptID: staleAttemptID) + self.authService.startLogin() + let replacementAttemptID = self.authService.activeLoginAttemptID + + self.authService.cancelLoginIfNeeded(expectedAttemptID: staleAttemptID) + + #expect(self.authService.activeLoginAttemptID == replacementAttemptID) + #expect(self.authService.state == .loggingIn) + } + @Test("Cancel login from signed out remains signed out") func cancelLoginFromSignedOutStaysSignedOut() async { await self.authService.checkLoginStatus() @@ -107,6 +136,11 @@ struct AuthServiceTests { @Test("Complete login waits for account-boundary drain") func completeLoginWaitsForAccountBoundaryDrain() async { + self.authService.startLogin() + guard let attemptID = self.authService.activeLoginAttemptID else { + Issue.record("Expected an active login attempt") + return + } let drainStarted = AsyncGate() let releaseDrain = AsyncGate() self.authService.setAccountBoundaryHandlers( @@ -119,16 +153,102 @@ struct AuthServiceTests { ) let completionTask = Task { - await self.authService.completeLoginAfterDraining(sapisid: "test-sapisid") + await self.authService.completeLoginAfterDraining( + expectedAttemptID: attemptID, + persistBeforeCommit: { "test-sapisid" }, + persistFinalSession: { "test-sapisid" }, + willPublishLogin: {} + ) } await drainStarted.wait() - #expect(self.authService.state == .initializing) + #expect(self.authService.state == .loggingIn) await releaseDrain.open() - await completionTask.value + let didComplete = await completionTask.value + #expect(didComplete) #expect(self.authService.state == .loggedIn(sapisid: "test-sapisid")) } + @Test("Cancelling login while completion drains prevents authentication") + func cancelLoginWhileCompletionDrains() async { + await self.authService.checkLoginStatus() + self.authService.startLogin() + guard let attemptID = self.authService.activeLoginAttemptID else { + Issue.record("Expected an active login attempt") + return + } + + let drainStarted = AsyncGate() + let releaseDrain = AsyncGate() + self.authService.setAccountBoundaryHandlers( + willBegin: {}, + didEnd: {}, + drain: { + await drainStarted.open() + await releaseDrain.wait() + } + ) + + let completionTask = Task { @MainActor in + await self.authService.completeLoginAfterDraining( + expectedAttemptID: attemptID, + persistBeforeCommit: { "replacement-session" }, + persistFinalSession: { "replacement-session" }, + willPublishLogin: {} + ) + } + await drainStarted.wait() + + self.authService.cancelLoginIfNeeded() + await releaseDrain.open() + let didComplete = await completionTask.value + + #expect(!didComplete) + #expect(self.authService.state == .loggedOut) + } + + @Test("A newer login attempt supersedes a draining completion") + func newerLoginAttemptSupersedesDrainingCompletion() async { + await self.authService.checkLoginStatus() + self.authService.startLogin() + guard let attemptID = self.authService.activeLoginAttemptID else { + Issue.record("Expected an active login attempt") + return + } + + let drainStarted = AsyncGate() + let releaseDrain = AsyncGate() + self.authService.setAccountBoundaryHandlers( + willBegin: {}, + didEnd: {}, + drain: { + await drainStarted.open() + await releaseDrain.wait() + } + ) + + let staleCompletion = Task { @MainActor in + await self.authService.completeLoginAfterDraining( + expectedAttemptID: attemptID, + persistBeforeCommit: { "stale-session" }, + persistFinalSession: { "stale-session" }, + willPublishLogin: {} + ) + } + await drainStarted.wait() + + self.authService.cancelLoginIfNeeded(expectedAttemptID: attemptID) + self.authService.startLogin() + let replacementAttemptID = self.authService.activeLoginAttemptID + await releaseDrain.open() + let didCompleteStaleAttempt = await staleCompletion.value + + #expect(!didCompleteStaleAttempt) + #expect(replacementAttemptID != attemptID) + #expect(self.authService.activeLoginAttemptID == replacementAttemptID) + #expect(self.authService.state == .loggingIn) + } + @Test("Session expired transitions to loggedOut and sets needsReauth") func sessionExpired() { self.authService.completeLogin(sapisid: "test-sapisid") @@ -222,7 +342,7 @@ struct AuthServiceTests { self.authService.completeLogin(sapisid: "placeholder-2") let signOutRequest = try self.storeCachedResponse(identifier: "sign-out") - await self.authService.signOut() + _ = await self.authService.signOut() #expect(await self.cachedResponseWasCleared(for: signOutRequest)) } @@ -319,13 +439,24 @@ struct AuthServiceTests { let guestTask = Task { await self.authService.enterGuestMode() } await drainStarted.wait() + self.authService.startLogin() + guard let attemptID = self.authService.activeLoginAttemptID else { + Issue.record("Expected an active login attempt") + return + } let loginTask = Task { - await self.authService.completeLoginAfterDraining(sapisid: "replacement-session") + await self.authService.completeLoginAfterDraining( + expectedAttemptID: attemptID, + persistBeforeCommit: { "replacement-session" }, + persistFinalSession: { "replacement-session" }, + willPublishLogin: {} + ) } await releaseDrain.open() await guestTask.value - await loginTask.value + let didComplete = await loginTask.value + #expect(didComplete) #expect(!self.authService.isGuestModeEnabled) #expect(self.authService.hasPersonalAccount) #expect(self.authService.state == .loggedIn(sapisid: "replacement-session")) @@ -353,7 +484,7 @@ struct AuthServiceTests { #expect(FavoritesManager.shared.activeScopeID != "guest") await self.authService.enterGuestMode() - await self.authService.signOut() + _ = await self.authService.signOut() #expect(self.authService.isGuestModeEnabled == false) #expect(self.authService.hasPersonalAccount == false) } @@ -371,13 +502,51 @@ struct AuthServiceTests { self.authService.completeLogin(sapisid: "test-sapisid") self.authService.needsReauth = true - await self.authService.signOut() + let didSignOutDurably = await self.authService.signOut() + #expect(didSignOutDurably) #expect(self.authService.state == .loggedOut) #expect(self.authService.needsReauth == false) + #expect(self.mockWebKitManager.invalidateAuthCookieRestorationCalled) #expect(self.mockWebKitManager.clearAllDataCalled == true) } + @Test("Sign out reports durable invalidation failure") + func signOutReportsDurableFailure() async { + self.authService.completeLogin(sapisid: "test-sapisid") + self.mockWebKitManager.clearAllDataResult = false + + let didSignOutDurably = await self.authService.signOut() + + #expect(!didSignOutDurably) + #expect(self.authService.state == .loggedOut) + #expect(self.authService.loginCleanupRequired) + #expect(self.authService.shouldUseCookieFreePlaybackDataStore) + #expect(self.authService.shouldPersistGuestPlaybackState) + #expect(self.mockWebKitManager.clearAllDataCalled) + + self.mockWebKitManager.sapisidValue = "surviving-session" + await self.authService.checkLoginStatus() + + #expect(self.authService.state == .loggedOut) + #expect(self.authService.loginCleanupRequired) + #expect(self.mockWebKitManager.getSAPISIDCallCount == 0) + } + + @Test("Login-status checks do not consume an active login attempt") + func loginStatusCheckDoesNotConsumeActiveAttempt() async { + self.authService.startLogin() + let attemptID = self.authService.activeLoginAttemptID + self.mockWebKitManager.sapisidValue = "candidate-session" + + await self.authService.checkLoginStatus() + + #expect(self.authService.state == .loggingIn) + #expect(self.authService.activeLoginAttemptID == attemptID) + #expect(self.mockWebKitManager.waitForInitialCookieRestoreCallCount == 0) + #expect(self.mockWebKitManager.getSAPISIDCallCount == 0) + } + @Test("Check login status waits for restore and logs in from SAPISID") func checkLoginStatusLogsIn() async { self.authService.needsReauth = true @@ -393,6 +562,86 @@ struct AuthServiceTests { #expect(self.mockWebKitManager.callSequence == ["waitForInitialCookieRestore", "getSAPISID"]) } + @Test("Failed startup cleanup refuses surviving authentication cookies") + func failedStartupCleanupRefusesAuthenticationCookies() async { + self.mockWebKitManager.waitForInitialCookieRestoreResult = false + self.mockWebKitManager.sapisidValue = "surviving-session" + + await self.authService.checkLoginStatus() + + #expect(self.authService.state == .loggedOut) + #expect(self.authService.needsReauth) + #expect(self.authService.loginCleanupRequired) + #expect(self.mockWebKitManager.getSAPISIDCallCount == 0) + #expect(self.mockWebKitManager.callSequence == ["waitForInitialCookieRestore"]) + } + + @Test("Logged-out residual cleanup fences an in-flight login-status check") + func loggedOutResidualCleanupFencesLoginCheck() async { + self.authService.completeLogin(sapisid: "expired-session") + self.authService.sessionExpired() + self.mockWebKitManager.sapisidValue = "residual-session" + let cookieReadStarted = AsyncGate() + let releaseCookieRead = AsyncGate() + self.mockWebKitManager.getSAPISIDGate = { + await cookieReadStarted.open() + await releaseCookieRead.wait() + } + + let loginCheck = Task { @MainActor in + await self.authService.checkLoginStatus() + } + await cookieReadStarted.wait() + let cleanupStarted = AsyncGate() + let releaseCleanup = AsyncGate() + let cleanup = Task { @MainActor in + await self.authService.clearFailedLoginAfterDraining( + expectedAttemptID: LoginAttemptID(rawValue: 999), + expectedSignOutSequence: self.authService.signOutSequence, + clearCookies: { + await cleanupStarted.open() + await releaseCleanup.wait() + return true + } + ) + } + await cleanupStarted.wait() + #expect(self.authService.state == .loggedOut) + #expect(self.authService.loginCleanupRequired) + + await releaseCookieRead.open() + await loginCheck.value + #expect(self.authService.state == .loggedOut) + + await releaseCleanup.open() + #expect(await cleanup.value == true) + #expect(self.authService.state == .loggedOut) + #expect(!self.authService.loginCleanupRequired) + } + + @Test("Stale residual cleanup cannot overwrite a completed explicit sign-out") + func staleResidualCleanupCannotOverwriteSignOut() async { + self.authService.completeLogin(sapisid: "expired-session") + self.authService.sessionExpired() + let staleSignOutSequence = self.authService.signOutSequence + + #expect(await self.authService.signOut()) + let cleanupCalled = LockedCounter() + let result = await self.authService.clearFailedLoginAfterDraining( + expectedAttemptID: LoginAttemptID(rawValue: 999), + expectedSignOutSequence: staleSignOutSequence, + clearCookies: { + cleanupCalled.increment() + return true + } + ) + + #expect(result == nil) + #expect(cleanupCalled.isEmpty) + #expect(self.authService.state == .loggedOut) + #expect(!self.authService.needsReauth) + } + @Test("Checking login status fences replacement of a logged-in identity") func checkLoginStatusFencesIdentityReplacement() async { self.authService.completeLogin(sapisid: "session-a") @@ -491,7 +740,7 @@ struct AuthServiceTests { self.mockWebKitManager.clearAllDataGate = { await release.wait() } let signOut = Task { @MainActor in - await self.authService.signOut() + _ = await self.authService.signOut() } for _ in 0 ..< 100 where !self.mockWebKitManager.clearAllDataCalled { await Task.yield() @@ -499,6 +748,7 @@ struct AuthServiceTests { #expect(self.authService.state == .loggedOut) #expect(self.authService.accountIdentityGeneration == identityGeneration &+ 1) + #expect(self.mockWebKitManager.invalidateAuthCookieRestorationCalled) if let cachedRequest { #expect(await self.cachedResponseWasCleared(for: cachedRequest)) } @@ -513,7 +763,7 @@ struct AuthServiceTests { #expect(self.mockWebKitManager.getSAPISIDCallCount == 0) await release.open() - await signOut.value + _ = await signOut.value await loginCheck.value #expect(self.authService.state == .loggedOut) #expect(self.mockWebKitManager.getSAPISIDCallCount == 0) diff --git a/Tests/KasetTests/Helpers/MockWebKitManager.swift b/Tests/KasetTests/Helpers/MockWebKitManager.swift index 836413ece..4c6481108 100644 --- a/Tests/KasetTests/Helpers/MockWebKitManager.swift +++ b/Tests/KasetTests/Helpers/MockWebKitManager.swift @@ -10,7 +10,24 @@ final class MockWebKitManager: WebKitManagerProtocol { var allCookies: [HTTPCookie] = [] var sapisidValue: String? var getSAPISIDGate: (@Sendable () async -> Void)? + var clearAuthCookiesGate: (@Sendable () async -> Void)? var clearAllDataGate: (@Sendable () async -> Void)? + var forceBackupCookiesGate: (@Sendable () async -> Void)? + var finalizeLoginCookieBackupGate: (@Sendable () async -> Void)? + var invalidateAuthCookieRestorationResult = true + var clearAuthCookiesResult = true + var clearAllDataResult = true + var forceBackupCookiesResult = true + var forceBackupCookiesResults: [Bool] = [] + var beginLoginCookieBackupResult = true + var commitLoginCookieBackupResult = true + var finalizeLoginCookieBackupResult = true + var loginCookieSessionValue = "candidate-session" + var loginCookieSessionValues: [String] = [] + var loginCookieSnapshotChanged = true + var rollbackLoginCookieBackupResult: CookieBackupRollbackResult = .rolledBack + var loginCookieBackupSetupRequiresCleanup = false + var waitForInitialCookieRestoreResult = true /// When set, `switchSessionIdentity` throws this error instead of succeeding. var switchSessionIdentityError: Error? @@ -39,9 +56,18 @@ final class MockWebKitManager: WebKitManagerProtocol { private(set) var getSAPISIDCalled = false private(set) var getSAPISIDCallCount = 0 private(set) var hasAuthCookiesCalled = false + private(set) var invalidateAuthCookieRestorationCalled = false private(set) var clearAuthCookiesCalled = false private(set) var clearAllDataCalled = false private(set) var forceBackupCookiesCalled = false + private(set) var forceBackupCookiesCallCount = 0 + private(set) var beginLoginCookieBackupCallCount = 0 + private(set) var refreshLoginCookieBackupCallCount = 0 + private(set) var commitLoginCookieBackupCallCount = 0 + private(set) var finalizeLoginCookieBackupCallCount = 0 + private(set) var rollbackLoginCookieBackupCallCount = 0 + private var nextCookieBackupTransactionID: UInt64 = 0 + private var activeCookieBackupTransaction: CookieBackupTransaction? private(set) var waitForInitialCookieRestoreCalled = false private(set) var waitForInitialCookieRestoreCallCount = 0 private(set) var logAuthCookiesCalled = false @@ -87,29 +113,129 @@ final class MockWebKitManager: WebKitManagerProtocol { return self.sapisidValue != nil } - func clearAuthCookies() async { + @discardableResult + func invalidateAuthCookieRestoration() -> Bool { + self.invalidateAuthCookieRestorationCalled = true + self.callSequence.append("invalidateAuthCookieRestoration") + return self.invalidateAuthCookieRestorationResult + } + + @discardableResult + func clearAuthCookies() async -> Bool { self.clearAuthCookiesCalled = true + self.callSequence.append("clearAuthCookies") + await self.clearAuthCookiesGate?() self.sapisidValue = nil - self.allCookies.removeAll { KeychainCookieStorage.authCookieNames.contains($0.name) } + self.activeCookieBackupTransaction = nil + self.allCookies.removeAll(where: KeychainCookieStorage.isAuthCookie) + self.loginCookieBackupSetupRequiresCleanup = !self.clearAuthCookiesResult + return self.clearAuthCookiesResult } - func clearAllData() async { + @discardableResult + func clearAllData() async -> Bool { self.clearAllDataCalled = true + self.callSequence.append("clearAllData") await self.clearAllDataGate?() // Does NOT clear real data - this is a mock self.allCookies = [] self.sapisidValue = nil + self.activeCookieBackupTransaction = nil + self.loginCookieBackupSetupRequiresCleanup = !self.clearAllDataResult + return self.clearAllDataResult } - func forceBackupCookies() async { + func forceBackupCookies() async -> Bool { self.forceBackupCookiesCalled = true + self.forceBackupCookiesCallCount += 1 + self.callSequence.append("forceBackupCookies") + await self.forceBackupCookiesGate?() // Does NOT interact with real file storage + if !self.forceBackupCookiesResults.isEmpty { + return self.forceBackupCookiesResults.removeFirst() + } + return self.forceBackupCookiesResult + } + + func beginLoginCookieBackup() async -> CookieBackupTransaction? { + self.beginLoginCookieBackupCallCount += 1 + self.callSequence.append("beginLoginCookieBackup") + guard self.beginLoginCookieBackupResult else { return nil } + self.nextCookieBackupTransactionID &+= 1 + let transaction = CookieBackupTransaction.testing(id: self.nextCookieBackupTransactionID) + self.activeCookieBackupTransaction = transaction + return transaction + } + + func isLoginCookieBackupActive(_ transaction: CookieBackupTransaction) async -> Bool { + self.activeCookieBackupTransaction?.matches(transaction) == true + } + + func hasLoginCookieSnapshotChanged(_ transaction: CookieBackupTransaction) async -> Bool { + self.activeCookieBackupTransaction?.matches(transaction) == true + && self.loginCookieSnapshotChanged + } + + func refreshLoginCookieBackup(_: CookieBackupTransaction) async -> Bool { + self.refreshLoginCookieBackupCallCount += 1 + self.callSequence.append("refreshLoginCookieBackup") + return await self.forceBackupCookies() + } + + func commitLoginCookieBackup( + _ transaction: CookieBackupTransaction + ) async -> String? { + self.commitLoginCookieBackupCallCount += 1 + self.callSequence.append("commitLoginCookieBackup") + guard await self.refreshLoginCookieBackup(transaction), + self.commitLoginCookieBackupResult + else { return nil } + if !self.loginCookieSessionValues.isEmpty { + return self.loginCookieSessionValues.removeFirst() + } + return self.loginCookieSessionValue + } + + func finalizeLoginCookieBackup( + _: CookieBackupTransaction + ) async -> String? { + self.finalizeLoginCookieBackupCallCount += 1 + self.callSequence.append("finalizeLoginCookieBackup") + await self.finalizeLoginCookieBackupGate?() + guard self.finalizeLoginCookieBackupResult else { return nil } + let finalSessionValue: String = if !self.loginCookieSessionValues.isEmpty { + self.loginCookieSessionValues.removeFirst() + } else { + self.loginCookieSessionValue + } + guard self.loginCookieSnapshotChanged else { return nil } + self.activeCookieBackupTransaction = nil + return finalSessionValue + } + + func prepareLoginCookieBackupRollback( + _: CookieBackupTransaction + ) async -> Bool { + self.callSequence.append("prepareLoginCookieBackupRollback") + return true + } + + func rollbackLoginCookieBackup( + _: CookieBackupTransaction + ) async -> CookieBackupRollbackResult { + self.rollbackLoginCookieBackupCallCount += 1 + self.callSequence.append("rollbackLoginCookieBackup") + if self.rollbackLoginCookieBackupResult != .failed { + self.activeCookieBackupTransaction = nil + } + return self.rollbackLoginCookieBackupResult } - func waitForInitialCookieRestore() async { + func waitForInitialCookieRestore() async -> Bool { self.waitForInitialCookieRestoreCalled = true self.waitForInitialCookieRestoreCallCount += 1 self.callSequence.append("waitForInitialCookieRestore") + return self.waitForInitialCookieRestoreResult } func logAuthCookies() async { @@ -161,10 +287,35 @@ final class MockWebKitManager: WebKitManagerProtocol { self.getSAPISIDCallCount = 0 self.getSAPISIDGate = nil self.hasAuthCookiesCalled = false + self.invalidateAuthCookieRestorationCalled = false + self.invalidateAuthCookieRestorationResult = true self.clearAuthCookiesCalled = false + self.clearAuthCookiesGate = nil + self.clearAuthCookiesResult = true self.clearAllDataCalled = false self.clearAllDataGate = nil + self.clearAllDataResult = true self.forceBackupCookiesCalled = false + self.forceBackupCookiesCallCount = 0 + self.forceBackupCookiesGate = nil + self.finalizeLoginCookieBackupGate = nil + self.forceBackupCookiesResult = true + self.forceBackupCookiesResults = [] + self.beginLoginCookieBackupResult = true + self.commitLoginCookieBackupResult = true + self.finalizeLoginCookieBackupResult = true + self.loginCookieSessionValue = "candidate-session" + self.loginCookieSessionValues = [] + self.rollbackLoginCookieBackupResult = .rolledBack + self.loginCookieBackupSetupRequiresCleanup = false + self.waitForInitialCookieRestoreResult = true + self.beginLoginCookieBackupCallCount = 0 + self.refreshLoginCookieBackupCallCount = 0 + self.commitLoginCookieBackupCallCount = 0 + self.finalizeLoginCookieBackupCallCount = 0 + self.rollbackLoginCookieBackupCallCount = 0 + self.nextCookieBackupTransactionID = 0 + self.activeCookieBackupTransaction = nil self.waitForInitialCookieRestoreCalled = false self.waitForInitialCookieRestoreCallCount = 0 self.logAuthCookiesCalled = false diff --git a/Tests/KasetTests/LoginCompletionGateTests.swift b/Tests/KasetTests/LoginCompletionGateTests.swift new file mode 100644 index 000000000..de37438e4 --- /dev/null +++ b/Tests/KasetTests/LoginCompletionGateTests.swift @@ -0,0 +1,631 @@ +import Testing +@testable import Kaset + +@Suite(.serialized, .tags(.service)) +@MainActor +struct LoginCompletionGateTests { + @Test("Persistence failure prevents login completion") + func persistenceFailurePreventsLoginCompletion() async { + let webKitManager = MockWebKitManager() + webKitManager.forceBackupCookiesResult = false + let authService = AuthService(webKitManager: webKitManager) + authService.startLogin() + guard let attemptID = authService.activeLoginAttemptID else { + Issue.record("Expected an active login attempt") + return + } + let gate = LoginCompletionGate( + webKitManager: webKitManager, + authService: authService + ) + guard let transaction = await webKitManager.beginLoginCookieBackup() else { + Issue.record("Expected a cookie backup transaction") + return + } + + let didComplete = await gate.complete( + expectedAttemptID: attemptID, + transaction: transaction + ) + + #expect(!didComplete) + #expect(webKitManager.beginLoginCookieBackupCallCount == 1) + #expect(webKitManager.forceBackupCookiesCallCount == 1) + #expect(webKitManager.commitLoginCookieBackupCallCount == 1) + #expect(webKitManager.finalizeLoginCookieBackupCallCount == 0) + #expect(authService.state == .initializing) + } + + @Test("Cancellation while persistence is in flight rolls back and prevents login completion") + func cancellationWhilePersistenceIsInFlight() async { + let webKitManager = MockWebKitManager() + let backupStarted = AsyncGate() + let releaseBackup = AsyncGate() + webKitManager.forceBackupCookiesGate = { + await backupStarted.open() + await releaseBackup.wait() + } + let authService = AuthService(webKitManager: webKitManager) + authService.startLogin() + guard let attemptID = authService.activeLoginAttemptID else { + Issue.record("Expected an active login attempt") + return + } + let gate = LoginCompletionGate( + webKitManager: webKitManager, + authService: authService + ) + guard let transaction = await webKitManager.beginLoginCookieBackup() else { + Issue.record("Expected a cookie backup transaction") + return + } + + let completionTask = Task { @MainActor in + await gate.complete( + expectedAttemptID: attemptID, + transaction: transaction + ) + } + await backupStarted.wait() + completionTask.cancel() + await releaseBackup.open() + + let didComplete = await completionTask.value + #expect(!didComplete) + #expect(webKitManager.rollbackLoginCookieBackupCallCount == 1) + #expect(authService.state == .initializing) + } + + @Test("A newer login attempt supersedes detection while persistence is in flight") + func newerAttemptDuringPersistenceIsRejected() async { + let webKitManager = MockWebKitManager() + let backupStarted = AsyncGate() + let releaseBackup = AsyncGate() + webKitManager.forceBackupCookiesGate = { + await backupStarted.open() + await releaseBackup.wait() + } + let authService = AuthService(webKitManager: webKitManager) + authService.startLogin() + guard let staleAttemptID = authService.activeLoginAttemptID else { + Issue.record("Expected an active login attempt") + return + } + let gate = LoginCompletionGate( + webKitManager: webKitManager, + authService: authService + ) + guard let transaction = await webKitManager.beginLoginCookieBackup() else { + Issue.record("Expected a cookie backup transaction") + return + } + + let completionTask = Task { @MainActor in + await gate.complete( + expectedAttemptID: staleAttemptID, + transaction: transaction + ) + } + await backupStarted.wait() + authService.cancelLoginIfNeeded(expectedAttemptID: staleAttemptID) + authService.startLogin() + let replacementAttemptID = authService.activeLoginAttemptID + await releaseBackup.open() + + let didComplete = await completionTask.value + #expect(!didComplete) + #expect(replacementAttemptID != staleAttemptID) + #expect(authService.activeLoginAttemptID == replacementAttemptID) + #expect(webKitManager.rollbackLoginCookieBackupCallCount == 1) + #expect(!webKitManager.clearAllDataCalled) + #expect(authService.state == .loggingIn) + } + + @Test("Fallback cookie clearing preserves ordinary guest semantics") + func fallbackCookieClearingPreservesGuestSemantics() async { + let webKitManager = MockWebKitManager() + webKitManager.commitLoginCookieBackupResult = false + webKitManager.rollbackLoginCookieBackupResult = .failed + let authService = AuthService(webKitManager: webKitManager) + await authService.checkLoginStatus() + authService.startLogin() + guard let attemptID = authService.activeLoginAttemptID, + let transaction = await webKitManager.beginLoginCookieBackup() + else { + Issue.record("Expected active login and cookie transaction") + return + } + let gate = LoginCompletionGate( + webKitManager: webKitManager, + authService: authService + ) + + let didComplete = await gate.complete( + expectedAttemptID: attemptID, + transaction: transaction + ) + + #expect(!didComplete) + #expect(authService.state == .loggedOut) + #expect(!authService.needsReauth) + #expect(authService.shouldUseCookieFreePlaybackDataStore) + #expect(authService.shouldPersistGuestPlaybackState) + } + + @Test("Fallback cookie clearing expires the prior authenticated state") + func fallbackCookieClearingExpiresPriorAuthentication() async { + let webKitManager = MockWebKitManager() + webKitManager.commitLoginCookieBackupResult = false + webKitManager.rollbackLoginCookieBackupResult = .failed + let authService = AuthService(webKitManager: webKitManager) + authService.completeLogin(sapisid: "existing-session") + authService.startLogin() + guard let attemptID = authService.activeLoginAttemptID, + let transaction = await webKitManager.beginLoginCookieBackup() + else { + Issue.record("Expected active login and cookie transaction") + return + } + let gate = LoginCompletionGate( + webKitManager: webKitManager, + authService: authService + ) + + let didComplete = await gate.complete( + expectedAttemptID: attemptID, + transaction: transaction + ) + + #expect(!didComplete) + #expect(webKitManager.clearAllDataCalled) + #expect(authService.state == .loggedOut) + #expect(authService.needsReauth) + #expect(!authService.loginCleanupRequired) + } + + @Test("Stable persistence commits the active login attempt") + func stablePersistenceCommitsLogin() async { + let webKitManager = MockWebKitManager() + let authService = AuthService(webKitManager: webKitManager) + authService.startLogin() + guard let attemptID = authService.activeLoginAttemptID else { + Issue.record("Expected an active login attempt") + return + } + let gate = LoginCompletionGate( + webKitManager: webKitManager, + authService: authService + ) + guard let transaction = await webKitManager.beginLoginCookieBackup() else { + Issue.record("Expected a cookie backup transaction") + return + } + + let didComplete = await gate.complete( + expectedAttemptID: attemptID, + transaction: transaction + ) + + #expect(didComplete) + #expect(webKitManager.forceBackupCookiesCallCount == 1) + #expect(webKitManager.refreshLoginCookieBackupCallCount == 1) + #expect(webKitManager.commitLoginCookieBackupCallCount == 1) + #expect(webKitManager.finalizeLoginCookieBackupCallCount == 1) + #expect(webKitManager.rollbackLoginCookieBackupCallCount == 0) + #expect(authService.state.isLoggedIn) + } + + @Test("Final cookie rotation updates the published session value") + func finalCookieRotationUpdatesPublishedSession() async { + let webKitManager = MockWebKitManager() + webKitManager.loginCookieSessionValues = ["session-before-finalize", "session-after-finalize"] + let authService = AuthService(webKitManager: webKitManager) + authService.startLogin() + guard let attemptID = authService.activeLoginAttemptID, + let transaction = await webKitManager.beginLoginCookieBackup() + else { + Issue.record("Expected active login and cookie transaction") + return + } + let gate = LoginCompletionGate( + webKitManager: webKitManager, + authService: authService + ) + + let didComplete = await gate.complete( + expectedAttemptID: attemptID, + transaction: transaction + ) + + #expect(didComplete) + #expect(authService.state == .loggedIn(sapisid: "session-after-finalize")) + } + + @Test("Final cookie capture runs inside the account-boundary drain") + func finalCookieCaptureRunsInsideDrain() async { + let webKitManager = MockWebKitManager() + let drainCount = LockedCounter() + webKitManager.finalizeLoginCookieBackupGate = { + #expect(drainCount.count == 1) + } + let authService = AuthService(webKitManager: webKitManager) + authService.setAccountBoundaryHandlers( + willBegin: {}, + didEnd: {}, + drain: { drainCount.increment() } + ) + authService.startLogin() + guard let attemptID = authService.activeLoginAttemptID, + let transaction = await webKitManager.beginLoginCookieBackup() + else { + Issue.record("Expected active login and cookie transaction") + return + } + let gate = LoginCompletionGate( + webKitManager: webKitManager, + authService: authService + ) + + let didComplete = await gate.complete( + expectedAttemptID: attemptID, + transaction: transaction + ) + + #expect(didComplete) + #expect(drainCount.count == 1) + } + + @Test("Login completion rejects an unchanged cookie snapshot") + func revertedSessionIsRejected() async { + let webKitManager = MockWebKitManager() + webKitManager.loginCookieSessionValue = "existing-session" + webKitManager.loginCookieSnapshotChanged = false + let authService = AuthService(webKitManager: webKitManager) + authService.completeLogin(sapisid: "existing-session") + authService.startLogin() + guard let attemptID = authService.activeLoginAttemptID, + let transaction = await webKitManager.beginLoginCookieBackup() + else { + Issue.record("Expected active login and cookie transaction") + return + } + let gate = LoginCompletionGate( + webKitManager: webKitManager, + authService: authService + ) + + let didComplete = await gate.complete( + expectedAttemptID: attemptID, + transaction: transaction + ) + + #expect(!didComplete) + #expect(webKitManager.rollbackLoginCookieBackupCallCount == 1) + #expect(authService.state == .loggedIn(sapisid: "existing-session")) + } + + @Test("Same primary cookie value completes when the cookie snapshot changed") + func samePrimaryCookieValueCompletesAfterSnapshotChange() async { + let webKitManager = MockWebKitManager() + webKitManager.loginCookieSessionValue = "existing-session" + let authService = AuthService(webKitManager: webKitManager) + authService.completeLogin(sapisid: "existing-session") + authService.startLogin() + guard let attemptID = authService.activeLoginAttemptID, + let transaction = await webKitManager.beginLoginCookieBackup() + else { + Issue.record("Expected active login and cookie transaction") + return + } + let gate = LoginCompletionGate( + webKitManager: webKitManager, + authService: authService + ) + + let didComplete = await gate.complete( + expectedAttemptID: attemptID, + transaction: transaction + ) + + #expect(didComplete) + #expect(webKitManager.rollbackLoginCookieBackupCallCount == 0) + #expect(authService.state == .loggedIn(sapisid: "existing-session")) + } + + @Test("Finalization rejects a snapshot that reverts to the pre-login state") + func finalRevertedSessionIsRejected() async { + let webKitManager = MockWebKitManager() + webKitManager.loginCookieSessionValues = ["replacement-session", "existing-session"] + webKitManager.loginCookieSnapshotChanged = false + let authService = AuthService(webKitManager: webKitManager) + authService.completeLogin(sapisid: "existing-session") + authService.startLogin() + guard let attemptID = authService.activeLoginAttemptID, + let transaction = await webKitManager.beginLoginCookieBackup() + else { + Issue.record("Expected active login and cookie transaction") + return + } + let gate = LoginCompletionGate( + webKitManager: webKitManager, + authService: authService + ) + + let didComplete = await gate.complete( + expectedAttemptID: attemptID, + transaction: transaction + ) + + #expect(!didComplete) + #expect(webKitManager.finalizeLoginCookieBackupCallCount == 1) + #expect(!webKitManager.clearAllDataCalled) + #expect(authService.state == .loggedIn(sapisid: "existing-session")) + } + + @Test("Cancellation during transaction finalization expires the new session") + func cancellationDuringFinalizationExpiresSession() async { + let webKitManager = MockWebKitManager() + webKitManager.rollbackLoginCookieBackupResult = .failed + let finalizationStarted = AsyncGate() + let releaseFinalization = AsyncGate() + webKitManager.finalizeLoginCookieBackupGate = { + await finalizationStarted.open() + await releaseFinalization.wait() + } + let authService = AuthService(webKitManager: webKitManager) + authService.startLogin() + guard let attemptID = authService.activeLoginAttemptID, + let transaction = await webKitManager.beginLoginCookieBackup() + else { + Issue.record("Expected active login and cookie transaction") + return + } + let gate = LoginCompletionGate( + webKitManager: webKitManager, + authService: authService + ) + + let completionTask = Task { @MainActor in + await gate.complete( + expectedAttemptID: attemptID, + transaction: transaction + ) + } + await finalizationStarted.wait() + completionTask.cancel() + await releaseFinalization.open() + + #expect(await completionTask.value == false) + #expect(webKitManager.clearAllDataCalled) + #expect(authService.state == .loggedOut) + } + + @Test("Finalization failure rolls back before clearing credentials") + func finalizationFailureUsesRollbackWhenAvailable() async { + let webKitManager = MockWebKitManager() + webKitManager.finalizeLoginCookieBackupResult = false + let authService = AuthService(webKitManager: webKitManager) + authService.startLogin() + guard let attemptID = authService.activeLoginAttemptID, + let transaction = await webKitManager.beginLoginCookieBackup() + else { + Issue.record("Expected active login and cookie transaction") + return + } + let gate = LoginCompletionGate( + webKitManager: webKitManager, + authService: authService + ) + + let didComplete = await gate.complete( + expectedAttemptID: attemptID, + transaction: transaction + ) + + #expect(!didComplete) + #expect(webKitManager.rollbackLoginCookieBackupCallCount == 1) + #expect(!webKitManager.clearAllDataCalled) + #expect(authService.state == .initializing) + #expect(!authService.loginCleanupRequired) + } + + @Test("Superseded finalization rollback clears owned credentials") + func supersededFinalizationRollbackClearsOwnedCredentials() async { + let webKitManager = MockWebKitManager() + webKitManager.finalizeLoginCookieBackupResult = false + webKitManager.rollbackLoginCookieBackupResult = .superseded + let authService = AuthService(webKitManager: webKitManager) + authService.startLogin() + guard let attemptID = authService.activeLoginAttemptID, + let transaction = await webKitManager.beginLoginCookieBackup() + else { + Issue.record("Expected active login and cookie transaction") + return + } + let gate = LoginCompletionGate( + webKitManager: webKitManager, + authService: authService + ) + + let didComplete = await gate.complete( + expectedAttemptID: attemptID, + transaction: transaction + ) + + #expect(!didComplete) + #expect(webKitManager.rollbackLoginCookieBackupCallCount == 1) + #expect(webKitManager.clearAllDataCalled) + #expect(authService.state == .loggedOut) + #expect(!authService.loginCleanupRequired) + } + + @Test("Transaction finalization failure expires the new session") + func finalizationFailureExpiresSession() async { + let webKitManager = MockWebKitManager() + webKitManager.finalizeLoginCookieBackupResult = false + webKitManager.rollbackLoginCookieBackupResult = .failed + let authService = AuthService(webKitManager: webKitManager) + authService.startLogin() + guard let attemptID = authService.activeLoginAttemptID, + let transaction = await webKitManager.beginLoginCookieBackup() + else { + Issue.record("Expected active login and cookie transaction") + return + } + let gate = LoginCompletionGate( + webKitManager: webKitManager, + authService: authService + ) + + let didComplete = await gate.complete( + expectedAttemptID: attemptID, + transaction: transaction + ) + + #expect(!didComplete) + #expect(webKitManager.finalizeLoginCookieBackupCallCount == 1) + #expect(webKitManager.clearAllDataCalled) + #expect(authService.state == .loggedOut) + } + + @Test("Failed durable cleanup blocks another sign-in attempt") + func failedDurableCleanupBlocksAnotherSignInAttempt() async { + let webKitManager = MockWebKitManager() + webKitManager.finalizeLoginCookieBackupResult = false + webKitManager.rollbackLoginCookieBackupResult = .failed + webKitManager.clearAllDataResult = false + let authService = AuthService(webKitManager: webKitManager) + authService.startLogin() + guard let attemptID = authService.activeLoginAttemptID, + let transaction = await webKitManager.beginLoginCookieBackup() + else { + Issue.record("Expected active login and cookie transaction") + return + } + let gate = LoginCompletionGate( + webKitManager: webKitManager, + authService: authService + ) + + let didComplete = await gate.complete( + expectedAttemptID: attemptID, + transaction: transaction + ) + + #expect(!didComplete) + #expect(webKitManager.clearAllDataCalled) + #expect(authService.state == .loggedOut) + #expect(authService.loginCleanupRequired) + authService.startLogin() + #expect(authService.activeLoginAttemptID == nil) + #expect(authService.state == .loggedOut) + } + + @Test("Stale cleanup cannot expire a replacement login attempt") + func staleCleanupCannotExpireReplacementLogin() async { + let webKitManager = MockWebKitManager() + webKitManager.finalizeLoginCookieBackupResult = false + webKitManager.rollbackLoginCookieBackupResult = .failed + let cleanupStarted = AsyncGate() + let releaseCleanup = AsyncGate() + webKitManager.clearAllDataGate = { + await cleanupStarted.open() + await releaseCleanup.wait() + } + let authService = AuthService(webKitManager: webKitManager) + authService.startLogin() + guard let attemptID = authService.activeLoginAttemptID, + let transaction = await webKitManager.beginLoginCookieBackup() + else { + Issue.record("Expected active login and cookie transaction") + return + } + let gate = LoginCompletionGate( + webKitManager: webKitManager, + authService: authService + ) + + let completion = Task { @MainActor in + await gate.complete( + expectedAttemptID: attemptID, + transaction: transaction + ) + } + await cleanupStarted.wait() + #expect(authService.state == .loggingIn) + #expect(authService.activeLoginAttemptID == attemptID) + authService.sessionExpired() + authService.startLogin() + let replacementAttemptID = authService.activeLoginAttemptID + await releaseCleanup.open() + + #expect(await completion.value == false) + #expect(replacementAttemptID != nil) + #expect(authService.activeLoginAttemptID == replacementAttemptID) + #expect(authService.state == .loggingIn) + #expect(!authService.loginCleanupRequired) + } + + @Test("Superseded rollback clears credentials owned by the active attempt") + func supersededRollbackClearsActiveAttemptCredentials() async { + let webKitManager = MockWebKitManager() + webKitManager.commitLoginCookieBackupResult = false + webKitManager.rollbackLoginCookieBackupResult = .superseded + let authService = AuthService(webKitManager: webKitManager) + authService.startLogin() + guard let attemptID = authService.activeLoginAttemptID, + let transaction = await webKitManager.beginLoginCookieBackup() + else { + Issue.record("Expected active login and cookie transaction") + return + } + let gate = LoginCompletionGate( + webKitManager: webKitManager, + authService: authService + ) + + let didComplete = await gate.complete( + expectedAttemptID: attemptID, + transaction: transaction + ) + + #expect(!didComplete) + #expect(webKitManager.clearAllDataCalled) + #expect(authService.state == .loggedOut) + } + + @Test("Restoration-policy commit failure rolls back before authentication") + func commitFailureExpiresSession() async { + let webKitManager = MockWebKitManager() + webKitManager.commitLoginCookieBackupResult = false + let authService = AuthService(webKitManager: webKitManager) + authService.startLogin() + guard let attemptID = authService.activeLoginAttemptID else { + Issue.record("Expected an active login attempt") + return + } + let gate = LoginCompletionGate( + webKitManager: webKitManager, + authService: authService + ) + guard let transaction = await webKitManager.beginLoginCookieBackup() else { + Issue.record("Expected a cookie backup transaction") + return + } + + let didComplete = await gate.complete( + expectedAttemptID: attemptID, + transaction: transaction + ) + + #expect(!didComplete) + #expect(webKitManager.forceBackupCookiesCallCount == 1) + #expect(webKitManager.refreshLoginCookieBackupCallCount == 1) + #expect(webKitManager.commitLoginCookieBackupCallCount == 1) + #expect(webKitManager.finalizeLoginCookieBackupCallCount == 0) + #expect(webKitManager.rollbackLoginCookieBackupCallCount == 1) + #expect(!webKitManager.clearAllDataCalled) + #expect(authService.state == .initializing) + #expect(!authService.needsReauth) + } +} diff --git a/Tests/KasetTests/WebKitAuthMaterialTests.swift b/Tests/KasetTests/WebKitAuthMaterialTests.swift index 4d33ad2ce..285c28191 100644 --- a/Tests/KasetTests/WebKitAuthMaterialTests.swift +++ b/Tests/KasetTests/WebKitAuthMaterialTests.swift @@ -60,6 +60,76 @@ struct WebKitAuthMaterialTests { #expect(matched == ["A", "B", "C"]) } + @Test("Auth cookie persistence accepts only Google and YouTube domain boundaries") + func authCookiePersistenceDomainBoundaries() throws { + #expect(KeychainCookieStorage.isAllowedAuthCookieDomain("youtube.com")) + #expect(KeychainCookieStorage.isAllowedAuthCookieDomain(".youtube.com")) + #expect(KeychainCookieStorage.isAllowedAuthCookieDomain("music.youtube.com")) + #expect(KeychainCookieStorage.isAllowedAuthCookieDomain("accounts.google.com")) + #expect(!KeychainCookieStorage.isAllowedAuthCookieDomain("notyoutube.com")) + #expect(!KeychainCookieStorage.isAllowedAuthCookieDomain("evilgoogle.com")) + #expect(!KeychainCookieStorage.isAllowedAuthCookieDomain("youtube.com.example.com")) + + let validCookie = try Self.cookie( + name: "SID", + value: "mock-token", + domain: ".google.com" + ) + let lookalikeCookie = try Self.cookie( + name: "SID", + value: "mock-token", + domain: "evilgoogle.com" + ) + + #expect(KeychainCookieStorage.isAuthCookie(validCookie)) + #expect(!KeychainCookieStorage.isAuthCookie(lookalikeCookie)) + + let gaiaCookie = try Self.cookie( + name: "LSID", + value: "mock-gaia-token", + domain: "accounts.google.com" + ) + #expect(!KeychainCookieStorage.isAuthCookie(gaiaCookie)) + #expect(KeychainCookieStorage.isLoginDomainCookie(gaiaCookie)) + #expect(KeychainCookieStorage.isLoginSessionCookie(gaiaCookie)) + #expect(!KeychainCookieStorage.isLoginDomainCookie(lookalikeCookie)) + + let preferenceCookie = try Self.cookie( + name: "PREF", + value: "public-preference", + domain: ".youtube.com" + ) + #expect(!KeychainCookieStorage.isLoginSessionCookie(preferenceCookie)) + + #expect(KeychainCookieStorage.makeArchiveResult(from: [validCookie]) == .noPrimarySession) + #expect(CookieArchiveBackupAction.make(from: .noPrimarySession) == .invalidate) + } + + @Test("Cookie serialization failures retain the last good archive") + func cookieSerializationFailuresRetainLastGoodArchive() throws { + let primaryCookie = try Self.cookie( + name: "SAPISID", + value: "mock-primary-session", + domain: ".youtube.com" + ) + + let cookieFailure = KeychainCookieStorage.makeArchiveResult( + from: [primaryCookie], + serializeCookie: { _ in nil }, + serializeArchive: { _ in Data([0x01]) } + ) + let archiveFailure = KeychainCookieStorage.makeArchiveResult( + from: [primaryCookie], + serializeCookie: { _ in Data([0x01]) }, + serializeArchive: { _ in nil } + ) + + #expect(cookieFailure == .failure) + #expect(archiveFailure == .failure) + #expect(CookieArchiveBackupAction.make(from: cookieFailure) == .retainExisting) + #expect(CookieArchiveBackupAction.make(from: archiveFailure) == .retainExisting) + } + private static func cookie( name: String, value: String, diff --git a/Tests/KasetTests/WebKitCookieRestoreTests.swift b/Tests/KasetTests/WebKitCookieRestoreTests.swift new file mode 100644 index 000000000..731692c85 --- /dev/null +++ b/Tests/KasetTests/WebKitCookieRestoreTests.swift @@ -0,0 +1,323 @@ +import Foundation +import Testing +@testable import Kaset + +@Suite("WebKit cookie restoration", .serialized, .tags(.service)) +@MainActor +struct WebKitCookieRestoreTests { + @Test("Persisted archive replaces stale live authentication cookies") + func persistedArchiveReplacesStaleLiveCookies() async throws { + let expectedCookie = try #require(HTTPCookie(properties: [ + .name: "__Secure-3PAPISID", + .value: "expected-session", + .domain: ".youtube.com", + .path: "/", + ])) + let staleCookie = try #require(HTTPCookie(properties: [ + .name: "SAPISID", + .value: "stale-session", + .domain: ".youtube.com", + .path: "/", + ])) + let staleGAIA = try #require(HTTPCookie(properties: [ + .name: "LSID", + .value: "stale-gaia-session", + .domain: ".google.com", + .path: "/", + ])) + let publicPreference = try #require(HTTPCookie(properties: [ + .name: "PREF", + .value: "preserve-public-state", + .domain: ".youtube.com", + .path: "/", + ])) + let webKitManager = WebKitManager.makeTestInstance() + + _ = await webKitManager.clearAllData() + KeychainCookieStorage.saveCookies([expectedCookie]) + await webKitManager.dataStore.httpCookieStore.setCookie(staleCookie) + await webKitManager.dataStore.httpCookieStore.setCookie(staleGAIA) + await webKitManager.dataStore.httpCookieStore.setCookie(publicPreference) + + let restored = await webKitManager.restoreAuthCookiesFromBackup( + expectedGeneration: webKitManager.authCookieOperationFence.generation + ) + let cookies = await webKitManager.dataStore.httpCookieStore.allCookies() + _ = await webKitManager.clearAllData() + + #expect(restored) + #expect(cookies.first { $0.name == "__Secure-3PAPISID" }?.value == "expected-session") + #expect(cookies.contains { $0.name == "SAPISID" } == false) + #expect(cookies.contains { $0.name == "LSID" } == false) + #expect(cookies.first { $0.name == "PREF" }?.value == "preserve-public-state") + } + + @Test("Authentication cookie clearing removes login-session state and preserves preferences") + func authenticationCookieClearingPreservesPreferences() async throws { + let loginCookie = try #require(HTTPCookie(properties: [ + .name: "LSID", + .value: "mock-login-session", + .domain: ".google.com", + .path: "/", + ])) + let preferenceCookie = try #require(HTTPCookie(properties: [ + .name: "PREF", + .value: "public-preference", + .domain: ".youtube.com", + .path: "/", + ])) + var cookies = [loginCookie, preferenceCookie] + + let result = await LiveAuthCookieStoreClearer.clear( + operations: LiveAuthCookieStoreClearer.Operations( + readCookies: { cookies }, + deleteCookie: { deletedCookie in + cookies.removeAll { cookie in + cookie.name == deletedCookie.name + && cookie.domain == deletedCookie.domain + && cookie.path == deletedCookie.path + } + }, + removeAllCookies: { + cookies = [] + } + ) + ) + + #expect(result.didClear) + #expect(!result.usedCookieStoreFallback) + #expect(!cookies.contains { $0.name == "LSID" }) + #expect(cookies.first { $0.name == "PREF" }?.value == "public-preference") + } + + @Test("Cookie restore policy treats explicit invalidation as authoritative") + func cookieRestorePolicyTreatsInvalidationAsAuthoritative() { + var archiveReadCount = 0 + + let decision = CookieArchiveRestorePolicy.resolveRestoreDecision( + invalidationTombstonePresent: true, + storedPolicy: .allowed, + archiveResult: { + archiveReadCount += 1 + return .data(Data([0x01])) + }, + migrateLegacyArchive: { _ in + archiveReadCount += 1 + return .allowed + } + ) + + #expect(decision == .denied) + #expect(archiveReadCount == 0) + } + + @Test("Missing restore policy migrates a valid legacy archive") + func missingRestorePolicyMigratesValidLegacyArchive() throws { + let cookie = try #require(HTTPCookie(properties: [ + .name: "SAPISID", + .value: "legacy-session", + .domain: ".youtube.com", + .path: "/", + ])) + let archive = try #require(KeychainCookieStorage.makeArchiveData(from: [cookie])) + var migratedArchives: [Data] = [] + + let decision = CookieArchiveRestorePolicy.resolveRestoreDecision( + invalidationTombstonePresent: false, + storedPolicy: .notFound, + archiveResult: { .data(archive.data) }, + migrateLegacyArchive: { archiveData in + migratedArchives.append(archiveData) + return KeychainCookieStorage.isRestorableArchiveData(archiveData) + ? .allowed + : .denied + } + ) + + #expect(decision == .allowed) + #expect(migratedArchives == [archive.data]) + #expect(!KeychainCookieStorage.isRestorableArchiveData(Data([0x01]))) + } + + @Test("Legacy restore-policy migration rejects a stale generation") + func legacyRestorePolicyMigrationRejectsStaleGeneration() throws { + let cookie = try #require(HTTPCookie(properties: [ + .name: "SAPISID", + .value: "legacy-session", + .domain: ".youtube.com", + .path: "/", + ])) + let archive = try #require(KeychainCookieStorage.makeArchiveData(from: [cookie])) + let staleGeneration = CookieArchiveRestorePolicy.generation + _ = CookieArchiveRestorePolicy.invalidateAndAdvanceGeneration() + var saveCount = 0 + + let decision = CookieArchiveRestorePolicy.migrateLegacyArchivePolicy( + archive.data, + expectedGeneration: staleGeneration, + savePolicy: { + saveCount += 1 + return true + } + ) + + #expect(decision == .denied) + #expect(saveCount == 0) + } + + @Test("Cookie restore policy distinguishes denial from temporary unavailability") + func cookieRestorePolicyDistinguishesUnavailableState() { + #expect(CookieArchiveRestorePolicy.resolveRestoreDecision( + invalidationTombstonePresent: false, + storedPolicy: .allowed, + archiveResult: { .failure }, + migrateLegacyArchive: { _ in .denied } + ) == .allowed) + #expect(CookieArchiveRestorePolicy.resolveRestoreDecision( + invalidationTombstonePresent: false, + storedPolicy: .denied, + archiveResult: { .notFound }, + migrateLegacyArchive: { _ in .allowed } + ) == .denied) + #expect(CookieArchiveRestorePolicy.resolveRestoreDecision( + invalidationTombstonePresent: false, + storedPolicy: .failure, + archiveResult: { .notFound }, + migrateLegacyArchive: { _ in .allowed } + ) == .unavailable) + #expect(CookieArchiveRestorePolicy.resolveRestoreDecision( + invalidationTombstonePresent: false, + storedPolicy: .notFound, + archiveResult: { .notFound }, + migrateLegacyArchive: { _ in .denied } + ) == .allowed) + #expect(CookieArchiveRestorePolicy.resolveRestoreDecision( + invalidationTombstonePresent: false, + storedPolicy: .notFound, + archiveResult: { .data(Data([0x01])) }, + migrateLegacyArchive: { _ in .denied } + ) == .denied) + #expect(CookieArchiveRestorePolicy.resolveRestoreDecision( + invalidationTombstonePresent: false, + storedPolicy: .notFound, + archiveResult: { .data(Data([0x01])) }, + migrateLegacyArchive: { _ in .unavailable } + ) == .unavailable) + #expect(CookieArchiveRestorePolicy.resolveRestoreDecision( + invalidationTombstonePresent: false, + storedPolicy: .notFound, + archiveResult: { .failure }, + migrateLegacyArchive: { _ in .allowed } + ) == .unavailable) + } + + @Test("Cookie restore policy storage rejects malformed representations") + func cookieRestorePolicyStorageRejectsMalformedRepresentations() { + #expect(CookieRestorePolicyStorage.decode(Data([0])) == .denied) + #expect(CookieRestorePolicyStorage.decode(Data([1])) == .allowed) + #expect(CookieRestorePolicyStorage.decode(Data()) == .failure) + #expect(CookieRestorePolicyStorage.decode(Data([2])) == .failure) + #expect(CookieRestorePolicyStorage.decode(Data([1, 0])) == .failure) + } + + @Test("Login baseline preparation fences observed cookie changes") + func loginBaselinePreparationFencesCookieChanges() { + let webKitManager = WebKitManager.makeTestInstance() + webKitManager.isPreparingLoginCookieBackup = true + webKitManager.forcedCookieBackupDirty = false + + webKitManager.handleObservedCookieChange( + in: webKitManager.dataStore.httpCookieStore + ) + + #expect(webKitManager.forcedCookieBackupDirty) + #expect(webKitManager.cookieDebounceTask == nil) + webKitManager.isPreparingLoginCookieBackup = false + } + + @Test("Failed restore quarantine clears login state and preserves preferences") + func failedRestoreQuarantineClearsLoginState() async throws { + let primaryCookie = try #require(HTTPCookie(properties: [ + .name: "SAPISID", + .value: "partial-session", + .domain: ".youtube.com", + .path: "/", + ])) + let loginCookie = try #require(HTTPCookie(properties: [ + .name: "LSID", + .value: "partial-login-state", + .domain: ".google.com", + .path: "/", + ])) + let preferenceCookie = try #require(HTTPCookie(properties: [ + .name: "PREF", + .value: "preserve-public-state", + .domain: ".youtube.com", + .path: "/", + ])) + let webKitManager = WebKitManager.makeTestInstance() + + _ = await webKitManager.clearAllData() + await webKitManager.dataStore.httpCookieStore.setCookie(primaryCookie) + await webKitManager.dataStore.httpCookieStore.setCookie(loginCookie) + await webKitManager.dataStore.httpCookieStore.setCookie(preferenceCookie) + let restorePolicyGeneration = CookieArchiveRestorePolicy.generation + + let didQuarantine = await webKitManager.quarantineFailedAuthCookieRestore( + expectedGeneration: webKitManager.authCookieOperationFence.generation + ) + let cookies = await webKitManager.dataStore.httpCookieStore.allCookies() + _ = await webKitManager.clearAllData() + + #expect(didQuarantine) + #expect(CookieArchiveRestorePolicy.generation > restorePolicyGeneration) + #expect(!cookies.contains { $0.name == "SAPISID" }) + #expect(!cookies.contains { $0.name == "LSID" }) + #expect(cookies.first { $0.name == "PREF" }?.value == "preserve-public-state") + } + + @Test("Login transaction snapshot identity includes refreshed companion cookies") + func loginTransactionSnapshotIdentityIncludesCookieMetadata() throws { + let primaryCookie = try #require(HTTPCookie(properties: [ + .name: "SAPISID", + .value: "stable-primary-value", + .domain: ".youtube.com", + .path: "/", + ])) + let baselineCompanion = try #require(HTTPCookie(properties: [ + .name: "SID", + .value: "baseline-companion", + .domain: ".google.com", + .path: "/", + ])) + let refreshedCompanion = try #require(HTTPCookie(properties: [ + .name: "SID", + .value: "refreshed-companion", + .domain: ".google.com", + .path: "/", + ])) + let baselineSnapshot = try #require(CookieArchiveSnapshot.make( + from: [primaryCookie, baselineCompanion] + )) + let refreshedSnapshot = try #require(CookieArchiveSnapshot.make( + from: [primaryCookie, refreshedCompanion] + )) + let transaction = CookieBackupTransaction.testing( + previousLiveSnapshotFingerprint: baselineSnapshot.stabilityFingerprint + ) + + #expect(baselineSnapshot.primarySessionValue == refreshedSnapshot.primarySessionValue) + #expect(!transaction.hasChangedFromPreviousLiveSnapshot(baselineSnapshot)) + #expect(transaction.hasChangedFromPreviousLiveSnapshot(refreshedSnapshot)) + #expect(WebKitManager.loginCookieBaselineMatches( + expectedAuthSnapshot: baselineSnapshot, + expectedLoginCookies: [primaryCookie, baselineCompanion], + currentCookies: [primaryCookie, baselineCompanion] + )) + #expect(!WebKitManager.loginCookieBaselineMatches( + expectedAuthSnapshot: baselineSnapshot, + expectedLoginCookies: [primaryCookie, baselineCompanion], + currentCookies: [primaryCookie, refreshedCompanion] + )) + } +} diff --git a/Tests/KasetTests/WebKitLoginCookieRollbackTests.swift b/Tests/KasetTests/WebKitLoginCookieRollbackTests.swift new file mode 100644 index 000000000..d2c146d39 --- /dev/null +++ b/Tests/KasetTests/WebKitLoginCookieRollbackTests.swift @@ -0,0 +1,73 @@ +import Foundation +import Testing +@testable import Kaset + +@Suite("WebKit login cookie rollback", .serialized, .tags(.service)) +@MainActor +struct WebKitLoginCookieRollbackTests { + @Test("Login rollback restores the complete Google and YouTube cookie jar") + func restoresCompleteSignInCookieJar() async throws { + let baselineAPI = try #require(HTTPCookie(properties: [ + .name: "SAPISID", + .value: "baseline-api-session", + .domain: ".youtube.com", + .path: "/", + ])) + let baselineGAIA = try #require(HTTPCookie(properties: [ + .name: "LSID", + .value: "baseline-gaia-session", + .domain: ".google.com", + .path: "/", + ])) + let unrelatedCookie = try #require(HTTPCookie(properties: [ + .name: "unrelated", + .value: "preserve-me", + .domain: ".example.com", + .path: "/", + ])) + let candidateAPI = try #require(HTTPCookie(properties: [ + .name: "SAPISID", + .value: "candidate-api-session", + .domain: ".youtube.com", + .path: "/", + ])) + let candidateGAIA = try #require(HTTPCookie(properties: [ + .name: "LSID", + .value: "candidate-gaia-session", + .domain: ".google.com", + .path: "/", + ])) + let candidateExtra = try #require(HTTPCookie(properties: [ + .name: "ACCOUNT_CHOOSER", + .value: "candidate-only", + .domain: ".google.com", + .path: "/", + ])) + let webKitManager = WebKitManager.makeTestInstance() + + _ = await webKitManager.clearAllData() + await webKitManager.dataStore.httpCookieStore.setCookie(baselineAPI) + await webKitManager.dataStore.httpCookieStore.setCookie(baselineGAIA) + await webKitManager.dataStore.httpCookieStore.setCookie(unrelatedCookie) + guard let transaction = await webKitManager.beginLoginCookieBackup() else { + _ = await webKitManager.clearAllData() + Issue.record("Expected an in-memory login cookie transaction") + return + } + + await webKitManager.dataStore.httpCookieStore.setCookie(candidateAPI) + await webKitManager.dataStore.httpCookieStore.setCookie(candidateGAIA) + await webKitManager.dataStore.httpCookieStore.setCookie(candidateExtra) + + #expect(await webKitManager.prepareLoginCookieBackupRollback(transaction)) + let rollbackResult = await webKitManager.rollbackLoginCookieBackup(transaction) + let cookies = await webKitManager.dataStore.httpCookieStore.allCookies() + _ = await webKitManager.clearAllData() + + #expect(rollbackResult == .rolledBack) + #expect(cookies.first { $0.name == "SAPISID" }?.value == "baseline-api-session") + #expect(cookies.first { $0.name == "LSID" }?.value == "baseline-gaia-session") + #expect(cookies.contains { $0.name == "ACCOUNT_CHOOSER" } == false) + #expect(cookies.first { $0.name == "unrelated" }?.value == "preserve-me") + } +} diff --git a/Tests/KasetTests/WebKitManagerTests.swift b/Tests/KasetTests/WebKitManagerTests.swift index e0014f05c..7e82499f5 100644 --- a/Tests/KasetTests/WebKitManagerTests.swift +++ b/Tests/KasetTests/WebKitManagerTests.swift @@ -3,6 +3,8 @@ import Testing import WebKit @testable import Kaset +// MARK: - WebKitManagerTests + /// Tests for WebKitManager. @Suite(.serialized, .tags(.service)) @MainActor @@ -168,6 +170,137 @@ struct WebKitManagerTests { #expect(hasAuth == false) } + @Test("Authentication cookie clears coalesce while one is in flight") + func authenticationCookieClearsCoalesce() async { + let coordinator = AuthCookieClearCoordinator() + let started = AsyncGate() + let release = AsyncGate() + let operationCount = LockedCounter() + + let first = Task { @MainActor in + await coordinator.run(scope: .authenticationCookies) { + operationCount.increment() + await started.open() + await release.wait() + return true + } + } + await started.wait() + let second = Task { @MainActor in + await coordinator.run(scope: .authenticationCookies) { + Issue.record("A coalesced authentication clear ran twice") + return false + } + } + await Task.yield() + await release.open() + + #expect(await first.value) + #expect(await second.value) + #expect(operationCount.count == 1) + #expect(!coordinator.isBusy) + } + + @Test("All-data clear waits for and escalates an authentication-only clear") + func allDataClearEscalatesAfterAuthenticationClear() async { + let coordinator = AuthCookieClearCoordinator() + let started = AsyncGate() + let release = AsyncGate() + let authClearCount = LockedCounter() + let allDataClearCount = LockedCounter() + + let authClear = Task { @MainActor in + await coordinator.run(scope: .authenticationCookies) { + authClearCount.increment() + await started.open() + await release.wait() + return true + } + } + await started.wait() + let allDataClear = Task { @MainActor in + await coordinator.run(scope: .allWebsiteData) { + allDataClearCount.increment() + return true + } + } + let secondAllDataClear = Task { @MainActor in + await coordinator.run(scope: .allWebsiteData) { + Issue.record("A queued all-data clear ran twice") + return false + } + } + await Task.yield() + #expect(allDataClearCount.isEmpty) + await release.open() + + #expect(await authClear.value) + #expect(await allDataClear.value) + #expect(await secondAllDataClear.value) + #expect(authClearCount.count == 1) + #expect(allDataClearCount.count == 1) + #expect(!coordinator.isBusy) + } + + @Test("Login cookie transactions are rejected while authentication cookies are clearing") + func loginCookieTransactionsAreRejectedDuringClear() async { + self.webKitManager.isClearingAuthCookies = true + + let transaction = await self.webKitManager.beginLoginCookieBackup() + + #expect(transaction == nil) + } + + @Test("Stale rollback preparation preserves a replacement restore policy") + func staleRollbackPreparationPreservesReplacementRestorePolicy() async { + let staleTransaction = CookieBackupTransaction.testing(id: 9001) + let replacementTransaction = CookieBackupTransaction.testing(id: 9002) + self.webKitManager.activeLoginCookieBackupTransaction = replacementTransaction + let restorePolicyGeneration = CookieArchiveRestorePolicy.generation + + let prepared = await self.webKitManager.prepareLoginCookieBackupRollback( + staleTransaction + ) + + #expect(prepared) + #expect(CookieArchiveRestorePolicy.generation == restorePolicyGeneration) + #expect(self.webKitManager.activeLoginCookieBackupTransaction?.matches( + replacementTransaction + ) == true) + } + + @Test("Sticky auth-cookie deletion escalates to clearing the cookie data store") + func stickyAuthCookieDeletionEscalates() async throws { + let authCookie = try #require(HTTPCookie(properties: [ + .name: "SAPISID", + .value: "mock-token", + .domain: ".youtube.com", + .path: "/", + ])) + var cookies = [authCookie] + var deleteCount = 0 + var fallbackCount = 0 + + let result = await LiveAuthCookieStoreClearer.clear( + operations: LiveAuthCookieStoreClearer.Operations( + readCookies: { cookies }, + deleteCookie: { _ in + deleteCount += 1 + // Simulate a sticky or immediately recreated cookie. + }, + removeAllCookies: { + fallbackCount += 1 + cookies = [] + } + ) + ) + + #expect(result.didClear) + #expect(result.usedCookieStoreFallback) + #expect(deleteCount == 3) + #expect(fallbackCount == 1) + } + @Test("Cookie archive write coordinator skips duplicate pending saves and retries after failure") func cookieArchiveWriteCoordinatorRetriesAfterFailure() { let coordinator = CookieArchiveWriteCoordinator() @@ -191,6 +324,411 @@ struct WebKitManagerTests { #expect(coordinator.beginSaveIfNeeded(archive) == false) } + @Test("Auth cookie operation fence invalidates an in-flight startup restore") + func authCookieOperationFenceInvalidatesRestore() { + var fence = AuthCookieOperationFence() + let restoreGeneration = fence.generation + + fence.invalidate() + + #expect(!fence.isCurrent(restoreGeneration)) + #expect(fence.isCurrent(fence.generation)) + } + + @Test("Cookie archive generation tracker rejects superseded snapshots") + func cookieArchiveGenerationTrackerRejectsSupersededSnapshots() { + var tracker = CookieArchiveGenerationTracker() + + let first = tracker.reserveGeneration() + let second = tracker.reserveGeneration() + + #expect(first < second) + #expect(!tracker.isLatest(first)) + #expect(tracker.isLatest(second)) + } + + @Test("Cookie archive queue prevents an older snapshot from overwriting a newer one") + func cookieArchiveQueueRejectsSupersededWrite() async { + let storage = InMemoryCookieArchiveStorage() + let queue = CookieArchiveWriteQueue(storage: storage.interface) + let olderGeneration = await queue.reserveGeneration() + let newerGeneration = await queue.reserveGeneration() + let olderArchive = Data([0x10]) + let newerArchive = Data([0x20]) + + let olderResult = await queue.save( + archiveData: olderArchive, + cookieCount: 1, + generation: olderGeneration + ) + let newerResult = await queue.save( + archiveData: newerArchive, + cookieCount: 1, + generation: newerGeneration + ) + + #expect(olderResult == .superseded) + #expect(newerResult == .saved) + #expect(storage.persistedData == newerArchive) + #expect(storage.saveCount == 1) + } + + @Test("Empty auth snapshots invalidate only at the latest generation") + func emptyAuthSnapshotsInvalidateOnlyWhenLatest() async { + let initialArchive = Data([0x2F]) + let storage = InMemoryCookieArchiveStorage(initialData: initialArchive) + let queue = CookieArchiveWriteQueue(storage: storage.interface) + let staleGeneration = await queue.reserveGeneration() + let latestGeneration = await queue.reserveGeneration() + + #expect(await queue.invalidateAndDeleteIfLatest( + generation: staleGeneration + ) == .superseded) + #expect(storage.persistedData == initialArchive) + #expect(await queue.isRestoreAllowed()) + + #expect(await queue.invalidateAndDeleteIfLatest( + generation: latestGeneration + ) == .saved) + #expect(storage.persistedData == nil) + #expect(await queue.isRestoreAllowed() == false) + } + + @Test("Cookie archive invalidation rejects a previously reserved write") + func cookieArchiveInvalidationRejectsReservedWrite() async { + let initialArchive = Data([0x30]) + let storage = InMemoryCookieArchiveStorage(initialData: initialArchive) + let queue = CookieArchiveWriteQueue(storage: storage.interface) + let reservedGeneration = await queue.reserveGeneration() + + let didInvalidate = await queue.invalidateAndDelete() + let staleResult = await queue.save( + archiveData: initialArchive, + cookieCount: 1, + generation: reservedGeneration + ) + + #expect(didInvalidate) + #expect(staleResult == .superseded) + #expect(storage.persistedData == nil) + #expect(storage.deleteCount == 1) + #expect(storage.saveCount == 0) + } + + @Test("Cookie archive invalidation reports persistent deletion failure") + func cookieArchiveInvalidationReportsDeletionFailure() async { + let initialArchive = Data([0x40]) + let storage = InMemoryCookieArchiveStorage( + initialData: initialArchive, + deleteResult: false + ) + let queue = CookieArchiveWriteQueue(storage: storage.interface) + + let didInvalidate = await queue.invalidateAndDelete() + + #expect(!didInvalidate) + #expect(storage.persistedData == initialArchive) + #expect(await queue.isRestoreAllowed() == false) + } + + @Test("Cookie archive invalidation includes the debug export") + func cookieArchiveInvalidationIncludesDebugExport() async { + let storage = InMemoryCookieArchiveStorage( + initialData: Data([0x44]), + debugDeleteResult: false, + exportsDebugArchive: true + ) + let queue = CookieArchiveWriteQueue(storage: storage.interface) + + let didInvalidate = await queue.invalidateAndDelete() + + #expect(!didInvalidate) + #expect(storage.persistedData == nil) + #expect(storage.debugDeleteCount == 1) + #expect(await queue.isRestoreAllowed() == false) + } + + @Test("Synchronous sign-out invalidation revokes older login transactions") + func signOutInvalidationRevokesOlderLoginTransactions() async throws { + let storage = InMemoryCookieArchiveStorage( + initialData: Data([0x42]), + restoreAllowed: true + ) + let queue = CookieArchiveWriteQueue(storage: storage.interface) + let transaction = try #require(await queue.beginLoginTransaction()) + + _ = CookieArchiveRestorePolicy.invalidateAndAdvanceGeneration() + + #expect(await queue.finalizeLoginTransaction(transaction) == false) + #expect(await queue.claimLoginTransactionRollback(transaction)) + #expect(await queue.rollbackLoginTransaction(transaction) == false) + #expect(storage.persistedData == Data([0x42])) + #expect(await queue.isRestoreAllowed() == false) + } + + @Test("Login transaction setup reports failed restore-policy rollback") + func loginTransactionSetupReportsPolicyRollbackFailure() async { + let storage = InMemoryCookieArchiveStorage( + initialData: Data([0x43]), + restoreAllowed: true, + setRestoreAllowedResults: [false, false] + ) + let queue = CookieArchiveWriteQueue(storage: storage.interface) + + #expect(await queue.beginLoginTransaction() == nil) + #expect(await queue.consumeLoginTransactionSetupCleanupRequirement()) + #expect(await queue.consumeLoginTransactionSetupCleanupRequirement() == false) + } + + @Test("Login transaction setup accepts successful restore-policy rollback") + func loginTransactionSetupAcceptsPolicyRollback() async { + let storage = InMemoryCookieArchiveStorage( + initialData: Data([0x44]), + restoreAllowed: true, + setRestoreAllowedResults: [false, true] + ) + let queue = CookieArchiveWriteQueue(storage: storage.interface) + + #expect(await queue.beginLoginTransaction() == nil) + #expect(await queue.consumeLoginTransactionSetupCleanupRequirement() == false) + #expect(await queue.isRestoreAllowed()) + } + + @Test("Login transaction ownership distinguishes active, rollback, and released states") + func loginTransactionOwnershipTracksLifecycle() async throws { + let storage = InMemoryCookieArchiveStorage(initialData: Data([0x45])) + let queue = CookieArchiveWriteQueue(storage: storage.interface) + let transaction = try #require(await queue.beginLoginTransaction()) + + #expect(await queue.loginTransactionOwnership(transaction) == .active) + #expect(await queue.claimLoginTransactionRollback(transaction)) + #expect(await queue.loginTransactionOwnership(transaction) == .rollingBack) + #expect(await queue.rollbackLoginTransaction(transaction)) + #expect(await queue.loginTransactionOwnership(transaction) == .none) + } + + @Test("Empty live login baseline removes stale archive and preserves restore policy") + func emptyLiveBaselineRemovesStaleArchiveOnRollback() async throws { + let staleArchive = Data([0x44]) + let candidateArchive = Data([0x45]) + let storage = InMemoryCookieArchiveStorage( + initialData: staleArchive, + restoreAllowed: true + ) + let queue = CookieArchiveWriteQueue(storage: storage.interface) + let transaction = try #require(await queue.beginLoginTransaction( + liveBaseline: .empty + )) + let generation = await queue.reserveGeneration() + #expect(await queue.save( + archiveData: candidateArchive, + cookieCount: 1, + generation: generation + ).isPersisted) + + #expect(transaction.revokeCommit()) + #expect(await queue.finalizeLoginTransaction(transaction) == false) + #expect(await queue.claimLoginTransactionRollback(transaction)) + #expect(await queue.rollbackLoginTransaction(transaction)) + #expect(storage.persistedData == nil) + #expect(await queue.isRestoreAllowed()) + } + + @Test("Login cookie transaction rollback restores the prior archive and restore policy") + func loginCookieTransactionRollbackRestoresPriorState() async throws { + let initialArchive = Data([0x45]) + let candidateArchive = Data([0x46]) + let storage = InMemoryCookieArchiveStorage(initialData: initialArchive) + let queue = CookieArchiveWriteQueue(storage: storage.interface) + let transaction = try #require(await queue.beginLoginTransaction()) + + #expect(await queue.isRestoreAllowed() == false) + let generation = await queue.reserveGeneration() + #expect(await queue.save( + archiveData: candidateArchive, + cookieCount: 1, + generation: generation + ).isPersisted) + + #expect(await queue.claimLoginTransactionRollback(transaction)) + #expect(await queue.rollbackLoginTransaction(transaction)) + #expect(storage.persistedData == initialArchive) + #expect(await queue.isRestoreAllowed()) + } + + @Test("Login cookie transaction commit makes only the prepared archive restorable") + func loginCookieTransactionCommitMakesCandidateRestorable() async throws { + let candidateArchive = Data([0x47]) + let storage = InMemoryCookieArchiveStorage( + initialData: nil, + restoreAllowed: false + ) + let queue = CookieArchiveWriteQueue(storage: storage.interface) + let transaction = try #require(await queue.beginLoginTransaction()) + let generation = await queue.reserveGeneration() + #expect(await queue.save( + archiveData: candidateArchive, + cookieCount: 1, + generation: generation + ).isPersisted) + + #expect(await queue.isRestoreAllowed() == false) + #expect(await queue.finalizeLoginTransaction(transaction)) + #expect(storage.persistedData == candidateArchive) + #expect(await queue.isRestoreAllowed()) + } + + @Test("Cookie archive stability includes SameSite policy") + func cookieArchiveStabilityIncludesSameSitePolicy() throws { + let laxCookie = try #require(HTTPCookie(properties: [ + .name: "SAPISID", + .value: "mock-token", + .domain: ".youtube.com", + .path: "/", + .sameSitePolicy: "Lax", + ])) + let strictCookie = try #require(HTTPCookie(properties: [ + .name: "SAPISID", + .value: "mock-token", + .domain: ".youtube.com", + .path: "/", + .sameSitePolicy: "Strict", + ])) + + let laxSnapshot = try #require(CookieArchiveSnapshot.make(from: [laxCookie])) + let strictSnapshot = try #require(CookieArchiveSnapshot.make(from: [strictCookie])) + + #expect(laxSnapshot != strictSnapshot) + } + + @Test("Rollback accepts an already-current prior archive") + func rollbackAcceptsAlreadyCurrentArchive() async throws { + let initialArchive = Data([0x4A]) + let storage = InMemoryCookieArchiveStorage( + initialData: initialArchive, + saveResult: false, + restoreAllowed: true + ) + let queue = CookieArchiveWriteQueue(storage: storage.interface) + let transaction = try #require(await queue.beginLoginTransaction()) + + #expect(await queue.claimLoginTransactionRollback(transaction)) + #expect(await queue.rollbackLoginTransaction(transaction)) + #expect(storage.persistedData == initialArchive) + #expect(await queue.isRestoreAllowed()) + } + + @Test("Failed login rollback keeps candidate archive non-restorable") + func failedLoginRollbackKeepsRestoreDisabled() async throws { + let candidateArchive = Data([0x48]) + let storage = InMemoryCookieArchiveStorage( + initialData: nil, + deleteResult: false, + restoreAllowed: true + ) + let queue = CookieArchiveWriteQueue(storage: storage.interface) + let transaction = try #require(await queue.beginLoginTransaction()) + let generation = await queue.reserveGeneration() + #expect(await queue.save( + archiveData: candidateArchive, + cookieCount: 1, + generation: generation + ).isPersisted) + + #expect(await queue.claimLoginTransactionRollback(transaction)) + #expect(await queue.rollbackLoginTransaction(transaction) == false) + #expect(storage.persistedData == candidateArchive) + #expect(await queue.isRestoreAllowed() == false) + } + + @Test("Overlapping login cookie transactions are rejected") + func overlappingLoginCookieTransactionsAreRejected() async throws { + let storage = InMemoryCookieArchiveStorage(initialData: Data([0x49])) + let queue = CookieArchiveWriteQueue(storage: storage.interface) + let first = try #require(await queue.beginLoginTransaction()) + + #expect(await queue.beginLoginTransaction() == nil) + #expect(await queue.claimLoginTransactionRollback(first)) + #expect(await queue.rollbackLoginTransaction(first)) + #expect(storage.persistedData == Data([0x49])) + #expect(await queue.isRestoreAllowed()) + } + + @Test("Empty auth-cookie rollback snapshots verify successfully") + func emptyAuthCookieRollbackSnapshotsVerifySuccessfully() { + let expected = CookieArchiveVerificationState.make(from: []) + let actual = CookieArchiveVerificationState.make(from: []) + + #expect(actual.matches(expected)) + } + + @Test("Forced backup requires two consecutive matching snapshots") + func forcedBackupRequiresConsecutiveMatchingSnapshots() async { + let first = Self.cookieArchiveSnapshot(marker: 0x50) + let second = Self.cookieArchiveSnapshot(marker: 0x60) + var snapshots = [first, second, second, second] + var generation: UInt64 = 0 + var persistedSnapshots: [CookieArchiveSnapshot] = [] + + let result = await CookieBackupStabilizer.persistStableSnapshot( + maxAttempts: 3, + operations: CookieBackupStabilizer.Operations( + prepareAttempt: {}, + makeAttempt: { + generation &+= 1 + return CookieArchiveWriteAttempt( + snapshot: snapshots.removeFirst(), + generation: generation + ) + }, + persist: { attempt in + persistedSnapshots.append(attempt.snapshot) + return .saved + }, + readVerificationSnapshot: { + snapshots.removeFirst() + }, + isDirty: { false }, + canContinue: { true } + ) + ) + + #expect(result == .persisted) + #expect(persistedSnapshots.count == 2) + #expect(persistedSnapshots.last == second) + } + + @Test("Forced backup reports persistence failure without claiming stability") + func forcedBackupReportsPersistenceFailure() async { + let snapshot = Self.cookieArchiveSnapshot(marker: 0x70) + + let result = await CookieBackupStabilizer.persistStableSnapshot( + maxAttempts: 3, + operations: CookieBackupStabilizer.Operations( + prepareAttempt: {}, + makeAttempt: { + CookieArchiveWriteAttempt(snapshot: snapshot, generation: 1) + }, + persist: { _ in .failed }, + readVerificationSnapshot: { snapshot }, + isDirty: { false }, + canContinue: { true } + ) + ) + + #expect(result == .failed) + } + + private static func cookieArchiveSnapshot(marker: UInt8) -> CookieArchiveSnapshot { + let data = Data([marker]) + return CookieArchiveSnapshot( + data: data, + cookieCount: 1, + primarySessionValue: "mock-token", + stabilityFingerprint: data + ) + } + @Test("Extension resource URL resolves relative paths against the extension base URL") func extensionResourceURLUsesExtensionBaseURL() throws { let baseURL = try #require(URL(string: "webkit-extension://example-extension/")) @@ -257,3 +795,100 @@ struct WebKitManagerTests { #expect(WebKitManager.dataSyncId("garbage-no-separator", matches: nil) == false) } } + +// MARK: - InMemoryCookieArchiveStorage + +private final class InMemoryCookieArchiveStorage: @unchecked Sendable { + private let lock = NSLock() + private var data: Data? + private let deleteResult: Bool + private let saveResult: Bool + private let debugDeleteResult: Bool + private let exportsDebugArchive: Bool + private var restoreAllowed: Bool + private var setRestoreAllowedResults: [Bool] + private var storedSaveCount = 0 + private var storedDeleteCount = 0 + private var storedDebugDeleteCount = 0 + + init( + initialData: Data? = nil, + deleteResult: Bool = true, + saveResult: Bool = true, + debugDeleteResult: Bool = true, + exportsDebugArchive: Bool = false, + restoreAllowed: Bool = true, + setRestoreAllowedResults: [Bool] = [] + ) { + self.data = initialData + self.deleteResult = deleteResult + self.saveResult = saveResult + self.debugDeleteResult = debugDeleteResult + self.exportsDebugArchive = exportsDebugArchive + self.restoreAllowed = restoreAllowed + self.setRestoreAllowedResults = setRestoreAllowedResults + } + + var interface: CookieArchiveStorage { + CookieArchiveStorage( + save: { [self] data, _ in + self.lock.withLock { + self.storedSaveCount += 1 + if self.saveResult { + self.data = data + } + } + return self.saveResult + }, + load: { [self] in + self.lock.withLock { self.data } + }, + delete: { [self] in + self.lock.withLock { + self.storedDeleteCount += 1 + if self.deleteResult { + self.data = nil + } + } + return self.deleteResult + }, + restoreDecision: { [self] in + self.lock.withLock { self.restoreAllowed ? .allowed : .denied } + }, + setRestoreAllowed: { [self] allowed in + self.lock.withLock { + let result = self.setRestoreAllowedResults.isEmpty + ? true + : self.setRestoreAllowedResults.removeFirst() + if result { + self.restoreAllowed = allowed + } + return result + } + }, + exportsDebugArchive: self.exportsDebugArchive, + deleteDebugArchive: { [self] in + self.lock.withLock { + self.storedDebugDeleteCount += 1 + } + return self.debugDeleteResult + } + ) + } + + var persistedData: Data? { + self.lock.withLock { self.data } + } + + var saveCount: Int { + self.lock.withLock { self.storedSaveCount } + } + + var deleteCount: Int { + self.lock.withLock { self.storedDeleteCount } + } + + var debugDeleteCount: Int { + self.lock.withLock { self.storedDebugDeleteCount } + } +} From 23665bbd281cc04a5fe9ddb87f8057cc08efb04f Mon Sep 17 00:00:00 2001 From: Sertac Ozercan Date: Wed, 29 Jul 2026 05:15:58 -0700 Subject: [PATCH 02/18] feat(api-explorer): add YouTube Ask parity tooling Signed-off-by: Sertac Ozercan --- Package.swift | 21 + Sources/APIExplorer/AskVideoAudit.swift | 2826 +++++++++++++++++ Sources/APIExplorer/main.swift | 1214 ++++++- .../YouTubeAskCore/YouTubeAskCoreError.swift | 18 + .../YouTubeAskJSONDuplicateKeyValidator.swift | 250 ++ .../YouTubeAskCore/YouTubeAskJSONValue.swift | 30 + Sources/YouTubeAskCore/YouTubeAskLimits.swift | 14 + .../YouTubeAskOpaqueCommand.swift | 33 + .../YouTubeAskParsedModels.swift | 29 + Sources/YouTubeAskCore/YouTubeAskParser.swift | 536 ++++ .../YouTubeAskRequestBuilder.swift | 65 + .../YouTubeAskRequestProfile.swift | 59 + .../YouTubeAskVisibleTextSanitizer.swift | 282 ++ .../YouTubeAskWireDecoder.swift | 359 +++ .../Fixtures/YouTubeAskConversation.json | 57 + .../Fixtures/YouTubeAskEligibleNext.json | 47 + .../Fixtures/YouTubeAskIneligibleNext.json | 24 + .../Fixtures/YouTubeAskInitialPanel.json | 30 + .../YouTubeAskFixtureSafetyTests.swift | 196 ++ .../YouTubeAskParserTests.swift | 393 +++ .../YouTubeAskRequestBuilderTests.swift | 108 + .../YouTubeAskRequestProfileTests.swift | 33 + .../YouTubeAskTestSupport.swift | 34 + .../YouTubeAskVisibleTextSanitizerTests.swift | 91 + .../YouTubeAskWireDecoderTests.swift | 253 ++ docs/api-discovery.md | 149 +- 26 files changed, 6979 insertions(+), 172 deletions(-) create mode 100644 Sources/APIExplorer/AskVideoAudit.swift create mode 100644 Sources/YouTubeAskCore/YouTubeAskCoreError.swift create mode 100644 Sources/YouTubeAskCore/YouTubeAskJSONDuplicateKeyValidator.swift create mode 100644 Sources/YouTubeAskCore/YouTubeAskJSONValue.swift create mode 100644 Sources/YouTubeAskCore/YouTubeAskLimits.swift create mode 100644 Sources/YouTubeAskCore/YouTubeAskOpaqueCommand.swift create mode 100644 Sources/YouTubeAskCore/YouTubeAskParsedModels.swift create mode 100644 Sources/YouTubeAskCore/YouTubeAskParser.swift create mode 100644 Sources/YouTubeAskCore/YouTubeAskRequestBuilder.swift create mode 100644 Sources/YouTubeAskCore/YouTubeAskRequestProfile.swift create mode 100644 Sources/YouTubeAskCore/YouTubeAskVisibleTextSanitizer.swift create mode 100644 Sources/YouTubeAskCore/YouTubeAskWireDecoder.swift create mode 100644 Tests/YouTubeAskCoreTests/Fixtures/YouTubeAskConversation.json create mode 100644 Tests/YouTubeAskCoreTests/Fixtures/YouTubeAskEligibleNext.json create mode 100644 Tests/YouTubeAskCoreTests/Fixtures/YouTubeAskIneligibleNext.json create mode 100644 Tests/YouTubeAskCoreTests/Fixtures/YouTubeAskInitialPanel.json create mode 100644 Tests/YouTubeAskCoreTests/YouTubeAskFixtureSafetyTests.swift create mode 100644 Tests/YouTubeAskCoreTests/YouTubeAskParserTests.swift create mode 100644 Tests/YouTubeAskCoreTests/YouTubeAskRequestBuilderTests.swift create mode 100644 Tests/YouTubeAskCoreTests/YouTubeAskRequestProfileTests.swift create mode 100644 Tests/YouTubeAskCoreTests/YouTubeAskTestSupport.swift create mode 100644 Tests/YouTubeAskCoreTests/YouTubeAskVisibleTextSanitizerTests.swift create mode 100644 Tests/YouTubeAskCoreTests/YouTubeAskWireDecoderTests.swift diff --git a/Package.swift b/Package.swift index ac5739159..8b517feae 100644 --- a/Package.swift +++ b/Package.swift @@ -27,6 +27,7 @@ let package = Package( .executableTarget( name: "Kaset", dependencies: [ + "YouTubeAskCore", .product(name: "Sparkle", package: "Sparkle"), ], exclude: [ @@ -65,6 +66,26 @@ let package = Package( // API Explorer CLI tool .executableTarget( name: "APIExplorer", + dependencies: ["YouTubeAskCore"], + swiftSettings: [ + .swiftLanguageMode(.v6), + ] + ), + // Shared Foundation-only YouTube Ask parsing and safety core + .target( + name: "YouTubeAskCore", + swiftSettings: [ + .swiftLanguageMode(.v6), + .enableExperimentalFeature("StrictConcurrency"), + ] + ), + // Unit tests for the shared YouTube Ask core + .testTarget( + name: "YouTubeAskCoreTests", + dependencies: ["YouTubeAskCore"], + resources: [ + .process("Fixtures"), + ], swiftSettings: [ .swiftLanguageMode(.v6), ] diff --git a/Sources/APIExplorer/AskVideoAudit.swift b/Sources/APIExplorer/AskVideoAudit.swift new file mode 100644 index 000000000..f0d44b77a --- /dev/null +++ b/Sources/APIExplorer/AskVideoAudit.swift @@ -0,0 +1,2826 @@ +import Foundation +import YouTubeAskCore + +/// A parity profile is eligible for selection only when YouTube explicitly +/// confirms the request was handled as signed in. A missing marker is not +/// equivalent to `loggedOut: false` and must fail closed. +func askParityHasConfirmedSignedInState(_ loggedOut: Bool?) -> Bool { + loggedOut == false +} + +func askVideoAuditSummary(_ response: [String: Any]) -> String { + var auditor = AskVideoResponseAuditor() + return auditor.audit(response).rendered() +} + +func wireResponseAuditSummary(data: Data, statusCode: Int, contentType: String?) -> String { + WireResponseAuditor.summary(data: data, statusCode: statusCode, contentType: contentType) +} + +func extractYouTubeMainAppJavaScriptURL(from html: String, baseURL: URL) -> URL? { + YouTubeMainAppScriptExtractor.extract(from: html, baseURL: baseURL) +} + +func youtubeAIFrontendCapabilitySummary(html: String, mainJavaScript: String?) -> String { + YouTubeAIFrontendCapabilityAuditor.summary(html: html, mainJavaScript: mainJavaScript) +} + +func youtubeAIFrontendFlowDebugSummary(mainJavaScript: String) -> String { + YouTubeAIFrontendFlowDebugAuditor.summary(mainJavaScript: mainJavaScript) +} + +// MARK: - Ask workflow + +private func fetchYouTubeWebResource( + _ url: URL, + authenticated: Bool +) async throws -> APIWireResponse { + guard url.scheme?.lowercased() == "https", + url.host?.lowercased() == "www.youtube.com", + url.port == nil || url.port == 443 + else { + throw NSError( + domain: "APIExplorer", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Only HTTPS www.youtube.com resources are allowed"] + ) + } + + let configuration = URLSessionConfiguration.ephemeral + if authenticated, let cookies = loadCookiesFromAppBackup(), !cookies.isEmpty { + let storage = HTTPCookieStorage() + for cookie in cookies { + storage.setCookie(cookie) + } + configuration.httpCookieStorage = storage + configuration.httpShouldSetCookies = true + configuration.httpCookieAcceptPolicy = .always + } + + var request = URLRequest(url: url) + request.timeoutInterval = 20 + request.setValue( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15", + forHTTPHeaderField: "User-Agent" + ) + + let (data, response) = try await boundedResponseData( + configuration: configuration, + request: request, + maximumBytes: maximumAskWebResourceBytes + ) + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.url?.scheme?.lowercased() == "https", + httpResponse.url?.host?.lowercased() == "www.youtube.com", + httpResponse.url?.port == nil || httpResponse.url?.port == 443 + else { + throw NSError( + domain: "APIExplorer", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Invalid or cross-origin web response"] + ) + } + + return APIWireResponse( + data: data, + statusCode: httpResponse.statusCode, + contentType: httpResponse.value(forHTTPHeaderField: "Content-Type") + ) +} + +private func normalizedMediaType(_ contentType: String?) -> String? { + contentType? + .split(separator: ";", maxSplits: 1) + .first + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } +} + +private func isValidYouTubeVideoID(_ value: String) -> Bool { + guard (6 ... 20).contains(value.count) else { return false } + return value.unicodeScalars.allSatisfy { scalar in + switch scalar.value { + case 45, 48 ... 57, 65 ... 90, 95, 97 ... 122: + true + default: + false + } + } +} + +private func serverLoggedOutState(in response: [String: Any]) -> Bool? { + if let responseContext = response["responseContext"] as? [String: Any], + let mainAppContext = responseContext["mainAppWebResponseContext"] as? [String: Any], + let loggedOut = mainAppContext["loggedOut"] as? Bool + { + return loggedOut + } + if let watchNextResponse = response["watchNextResponse"] as? [String: Any] { + return serverLoggedOutState(in: watchNextResponse) + } + return nil +} + +// MARK: - AskRuntimeWEBConfiguration + +private struct AskRuntimeWEBConfiguration { + let runtimeAPIIdentifier: String + let clientVersion: String + let visitorData: String +} + +private let maximumAskWebResourceBytes = 24 * 1024 * 1024 +private let maximumAskConfigurationResponseBytes = 8 * 1024 * 1024 + +// MARK: - AskParityFailureCategory + +private enum AskParityFailureCategory: String { + case none + case invalidVideoID = "invalid-video-id" + case authenticationUnavailable = "authentication-unavailable" + case unsupportedAccountSelection = "unsupported-account-selection" + case unsupportedOptions = "unsupported-options" + case runtimeConfigurationUnavailable = "runtime-configuration-unavailable" + case requestConfigurationUnavailable = "request-configuration-unavailable" + case nextNetworkFailure = "next-network-failure" + case nextResponseTooLarge = "next-response-too-large" + case nextAuthenticationRejected = "next-authentication-rejected" + case nextRateLimited = "next-rate-limited" + case nextHTTPFailure = "next-http-failure" + case nextDecodeFailure = "next-decode-failure" + case nextParseFailure = "next-parse-failure" + case ineligible + case panelCommandUnavailable = "panel-command-unavailable" + case panelNetworkFailure = "panel-network-failure" + case panelResponseTooLarge = "panel-response-too-large" + case panelAuthenticationRejected = "panel-authentication-rejected" + case panelRateLimited = "panel-rate-limited" + case panelHTTPFailure = "panel-http-failure" + case panelDecodeFailure = "panel-decode-failure" + case panelParseFailure = "panel-parse-failure" + case summarySuggestionUnavailable = "summary-suggestion-unavailable" +} + +// MARK: - AskParityStageMetrics + +private struct AskParityStageMetrics { + var statusCode: Int? + var byteCount: Int? + var wireFormat: String? + + var statusDescription: String { + self.statusCode.map(String.init) ?? "not-run" + } + + var sizeDescription: String { + self.byteCount.map(String.init) ?? "not-run" + } + + var formatDescription: String { + self.wireFormat ?? "not-run" + } +} + +// MARK: - AskParityReport + +private struct AskParityReport { + let profileName: String + var next = AskParityStageMetrics() + var panel = AskParityStageMetrics() + var eligibility = "unknown" + var nextChipCount = 0 + var panelChipCount = 0 + var failureCategory: AskParityFailureCategory + + func render() { + print("profile: \(self.profileName)") + print("status: next=\(self.next.statusDescription) panel=\(self.panel.statusDescription)") + print("size: next=\(self.next.sizeDescription) panel=\(self.panel.sizeDescription)") + print("format: next=\(self.next.formatDescription) panel=\(self.panel.formatDescription)") + print("eligibility: \(self.eligibility)") + print("chip-counts: next=\(self.nextChipCount) panel=\(self.panelChipCount)") + print("failure-category: \(self.failureCategory.rawValue)") + } +} + +// MARK: - AskParityEvaluation + +private struct AskParityEvaluation { + let report: AskParityReport + let passed: Bool +} + +// MARK: - AskParitySelection + +private struct AskParitySelection { + let profile: YouTubeAskRequestProfile + let runtimeConfiguration: AskRuntimeWEBConfiguration? + let cookies: [HTTPCookie] + + var profileName: String { + askParityProfileName(self.profile) + } +} + +// MARK: - AskParityRequestError + +private enum AskParityRequestError: Error { + case configurationUnavailable +} + +private let askParityUserAgent = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15" + +private func askWebClientConfigurationRequest(timeout: TimeInterval) -> URLRequest { + var request = URLRequest(url: activeWebClientURL) + request.timeoutInterval = timeout + request.setValue(askParityUserAgent, forHTTPHeaderField: "User-Agent") + return request +} + +private func askParityProfileName(_ profile: YouTubeAskRequestProfile) -> String { + if profile == .fixedProduction { + return "fixed-production-single-proof" + } + if profile == .fixedProductionWithAllSIDProofs { + return "fixed-production-all-proofs" + } + return "runtime-web-all-proofs" +} + +private func askParityWireFormat(_ envelope: YouTubeAskWireEnvelope) -> String { + let format = switch envelope.format { + case .jsonObject: + "json-object" + case .jsonArray: + "json-array" + case .newlineDelimitedJSON: + "newline-delimited-json" + case .lengthPrefixedJSON: + "length-prefixed-json" + } + return envelope.hadXSSIPrefix ? "xssi-\(format)" : format +} + +private func askParityHTTPFailureCategory( + statusCode: Int, + stage: String +) -> AskParityFailureCategory { + if statusCode == 401 || statusCode == 403 { + return stage == "next" ? .nextAuthenticationRejected : .panelAuthenticationRejected + } + if statusCode == 429 { + return stage == "next" ? .nextRateLimited : .panelRateLimited + } + return stage == "next" ? .nextHTTPFailure : .panelHTTPFailure +} + +private func askParityServerLoggedOutState( + in envelope: YouTubeAskWireEnvelope +) -> Bool? { + let maximumDepth = 40 + let maximumVisitedNodes = 50000 + var visitedNodes = 0 + var foundLoggedOut = false + var foundSignedIn = false + var traversalWasTruncated = false + + func collectLoggedOutValues(in value: YouTubeAskJSONValue, depth: Int) { + guard depth <= maximumDepth, visitedNodes < maximumVisitedNodes else { + traversalWasTruncated = true + return + } + visitedNodes += 1 + + switch value { + case let .object(object): + if let responseContext = object["responseContext"]?.objectValue, + let mainAppContext = responseContext["mainAppWebResponseContext"]?.objectValue, + case let .bool(loggedOut)? = mainAppContext["loggedOut"] + { + if loggedOut { + foundLoggedOut = true + } else { + foundSignedIn = true + } + } + for nestedValue in object.values { + collectLoggedOutValues(in: nestedValue, depth: depth + 1) + } + case let .array(array): + for nestedValue in array { + collectLoggedOutValues(in: nestedValue, depth: depth + 1) + } + default: + break + } + } + + for root in envelope.roots { + collectLoggedOutValues(in: root, depth: 0) + } + + // Any explicit logged-out marker rejects the response, including a + // conflicting stream that also contains `loggedOut: false`. If traversal + // was truncated, a lone signed-in marker cannot prove the unseen remainder + // is safe, so the result remains unknown and fails closed. + if foundLoggedOut { + return true + } + if foundSignedIn, !traversalWasTruncated { + return false + } + return nil +} + +private func askRuntimeWEBConfiguration(cookies: [HTTPCookie]) -> URLSessionConfiguration { + let configuration = URLSessionConfiguration.ephemeral + let storage = HTTPCookieStorage() + for cookie in cookies { + storage.setCookie(cookie) + } + configuration.httpCookieStorage = storage + configuration.httpShouldSetCookies = true + configuration.httpCookieAcceptPolicy = .always + return configuration +} + +private func resolveAskRuntimeWEBConfiguration( + cookies: [HTTPCookie] +) async throws -> AskRuntimeWEBConfiguration { + let request = askWebClientConfigurationRequest(timeout: 10) + let (data, response) = try await boundedResponseData( + configuration: askRuntimeWEBConfiguration(cookies: cookies), + request: request, + maximumBytes: maximumAskConfigurationResponseBytes + ) + guard let httpResponse = response as? HTTPURLResponse, + (200 ... 399).contains(httpResponse.statusCode), + let html = String(data: data, encoding: .utf8), + let runtimeAPIIdentifier = extractInnertubeAPIKey(from: html), + let clientVersion = extractInnertubeClientVersion(from: html), + let visitorData = extractConfigValue(named: "VISITOR_DATA", from: html), + !runtimeAPIIdentifier.isEmpty, !clientVersion.isEmpty, !visitorData.isEmpty + else { + throw AskParityRequestError.configurationUnavailable + } + return AskRuntimeWEBConfiguration( + runtimeAPIIdentifier: runtimeAPIIdentifier, + clientVersion: clientVersion, + visitorData: visitorData + ) +} + +private func askParityContext( + profile: YouTubeAskRequestProfile, + runtimeConfiguration: AskRuntimeWEBConfiguration? +) throws -> [String: Any] { + var client: [String: Any] = [ + "clientName": "WEB", + "clientVersion": profile.clientVersion, + "hl": "en", + "gl": "US", + "browserName": "Safari", + "browserVersion": "17.0", + "osName": "Macintosh", + "osVersion": "10_15_7", + "platform": "DESKTOP", + "userAgent": askParityUserAgent, + "utcOffsetMinutes": TimeZone.current.secondsFromGMT() / 60, + ] + if profile.usesVisitorData { + guard let runtimeConfiguration else { + throw AskParityRequestError.configurationUnavailable + } + client["visitorData"] = runtimeConfiguration.visitorData + } + return [ + "client": client, + "user": ["lockedSafetyMode": false], + ] +} + +private func makeAskParityWireRequest( + endpoint: String, + bodyData: Data, + profile: YouTubeAskRequestProfile, + runtimeConfiguration: AskRuntimeWEBConfiguration?, + cookies: [HTTPCookie] +) async throws -> APIWireResponse { + let endpoint = try canonicalAPIEndpoint(endpoint) + guard var body = try JSONSerialization.jsonObject(with: bodyData) as? [String: Any], + let cookieHeader = buildCookieHeader(from: cookies), + let authorization = buildSIDAuthorizationHeader( + from: cookies, + includeAllAvailableProofs: profile.usesAllSIDProofs + ) + else { + throw AskParityRequestError.configurationUnavailable + } + body["context"] = try askParityContext( + profile: profile, + runtimeConfiguration: runtimeConfiguration + ) + + var components = URLComponents(string: "\(activeBaseURL)/\(endpoint)") + var queryItems = [URLQueryItem(name: "prettyPrint", value: "false")] + if profile.includesRuntimeAPIParameter { + guard let runtimeConfiguration else { + throw AskParityRequestError.configurationUnavailable + } + queryItems.insert(URLQueryItem(name: "key", value: runtimeConfiguration.runtimeAPIIdentifier), at: 0) + } + components?.queryItems = queryItems + guard let url = components?.url else { + throw AskParityRequestError.configurationUnavailable + } + + var request = URLRequest( + url: url, + cachePolicy: .reloadIgnoringLocalCacheData, + timeoutInterval: 30 + ) + request.httpMethod = "POST" + request.httpShouldHandleCookies = false + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue(askParityUserAgent, forHTTPHeaderField: "User-Agent") + request.setValue(activeOrigin, forHTTPHeaderField: "Origin") + request.setValue(activeOrigin, forHTTPHeaderField: "Referer") + request.setValue(activeOrigin, forHTTPHeaderField: "X-Origin") + request.setValue("0", forHTTPHeaderField: "X-Goog-AuthUser") + request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + request.setValue(authorization, forHTTPHeaderField: "Authorization") + if profile.usesVisitorData { + guard let runtimeConfiguration else { + throw AskParityRequestError.configurationUnavailable + } + request.setValue(runtimeConfiguration.visitorData, forHTTPHeaderField: "X-Goog-Visitor-Id") + } + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + let (data, response) = try await boundedResponseData( + configuration: .ephemeral, + request: request, + maximumBytes: YouTubeAskLimits.maximumResponseBytes + ) + guard let httpResponse = response as? HTTPURLResponse else { + throw AskParityRequestError.configurationUnavailable + } + return APIWireResponse( + data: data, + statusCode: httpResponse.statusCode, + contentType: httpResponse.value(forHTTPHeaderField: "Content-Type") + ) +} + +// MARK: - AskParityNextEvaluation + +private struct AskParityNextEvaluation { + let report: AskParityReport + let bootstrap: YouTubeAskParsedBootstrap? +} + +private func evaluateAskParityNext( + profile: YouTubeAskRequestProfile, + videoID: String, + runtimeConfiguration: AskRuntimeWEBConfiguration?, + cookies: [HTTPCookie], + initialReport: AskParityReport +) async -> AskParityNextEvaluation { + var report = initialReport + guard let nextBody = try? JSONSerialization.data(withJSONObject: ["videoId": videoID]) else { + report.failureCategory = .requestConfigurationUnavailable + return AskParityNextEvaluation(report: report, bootstrap: nil) + } + + let nextResponse: APIWireResponse + do { + nextResponse = try await makeAskParityWireRequest( + endpoint: "next", + bodyData: nextBody, + profile: profile, + runtimeConfiguration: runtimeConfiguration, + cookies: cookies + ) + } catch is ResponseSizeLimitError { + report.failureCategory = .nextResponseTooLarge + return AskParityNextEvaluation(report: report, bootstrap: nil) + } catch AskParityRequestError.configurationUnavailable { + report.failureCategory = .requestConfigurationUnavailable + return AskParityNextEvaluation(report: report, bootstrap: nil) + } catch { + report.failureCategory = .nextNetworkFailure + return AskParityNextEvaluation(report: report, bootstrap: nil) + } + report.next.statusCode = nextResponse.statusCode + report.next.byteCount = nextResponse.data.count + + guard (200 ... 299).contains(nextResponse.statusCode) else { + report.failureCategory = askParityHTTPFailureCategory( + statusCode: nextResponse.statusCode, + stage: "next" + ) + return AskParityNextEvaluation(report: report, bootstrap: nil) + } + + let nextEnvelope: YouTubeAskWireEnvelope + do { + nextEnvelope = try YouTubeAskWireDecoder.decode(nextResponse.data) + report.next.wireFormat = askParityWireFormat(nextEnvelope) + } catch { + report.failureCategory = .nextDecodeFailure + return AskParityNextEvaluation(report: report, bootstrap: nil) + } + guard askParityHasConfirmedSignedInState( + askParityServerLoggedOutState(in: nextEnvelope) + ) else { + report.failureCategory = .nextAuthenticationRejected + return AskParityNextEvaluation(report: report, bootstrap: nil) + } + + do { + guard let bootstrap = try YouTubeAskParser.parseBootstrap(from: nextEnvelope) else { + report.eligibility = "ineligible" + report.failureCategory = .ineligible + return AskParityNextEvaluation(report: report, bootstrap: nil) + } + report.eligibility = "eligible" + report.nextChipCount = bootstrap.suggestions.count + return AskParityNextEvaluation(report: report, bootstrap: bootstrap) + } catch { + report.failureCategory = .nextParseFailure + return AskParityNextEvaluation(report: report, bootstrap: nil) + } +} + +private func evaluateAskParityPanel( + profile: YouTubeAskRequestProfile, + bootstrap: YouTubeAskParsedBootstrap, + runtimeConfiguration: AskRuntimeWEBConfiguration?, + cookies: [HTTPCookie], + nextReport: AskParityReport +) async -> AskParityEvaluation { + var report = nextReport + let nextHasSummarySuggestion = bootstrap.suggestions.contains { suggestion in + isAskSummaryLabel(suggestion.label) + } + + // Even when `next` already exposes the summary chip, parity still requires + // a read-only panel continuation so the exact `get_panel` transport used by + // live suggestions is validated without submitting that chip. + guard let panelCommand = bootstrap.panelCommand else { + report.failureCategory = .panelCommandUnavailable + return AskParityEvaluation(report: report, passed: false) + } + + let panelBody = YouTubeAskRequestBuilder.makePanelBootstrapBody(command: panelCommand) + let panelResponse: APIWireResponse + do { + panelResponse = try await makeAskParityWireRequest( + endpoint: "get_panel", + bodyData: panelBody, + profile: profile, + runtimeConfiguration: runtimeConfiguration, + cookies: cookies + ) + } catch is ResponseSizeLimitError { + report.failureCategory = .panelResponseTooLarge + return AskParityEvaluation(report: report, passed: false) + } catch AskParityRequestError.configurationUnavailable { + report.failureCategory = .requestConfigurationUnavailable + return AskParityEvaluation(report: report, passed: false) + } catch { + report.failureCategory = .panelNetworkFailure + return AskParityEvaluation(report: report, passed: false) + } + report.panel.statusCode = panelResponse.statusCode + report.panel.byteCount = panelResponse.data.count + + guard (200 ... 299).contains(panelResponse.statusCode) else { + report.failureCategory = askParityHTTPFailureCategory( + statusCode: panelResponse.statusCode, + stage: "panel" + ) + return AskParityEvaluation(report: report, passed: false) + } + + let panelEnvelope: YouTubeAskWireEnvelope + do { + panelEnvelope = try YouTubeAskWireDecoder.decode(panelResponse.data) + report.panel.wireFormat = askParityWireFormat(panelEnvelope) + } catch { + report.failureCategory = .panelDecodeFailure + return AskParityEvaluation(report: report, passed: false) + } + guard askParityHasConfirmedSignedInState( + askParityServerLoggedOutState(in: panelEnvelope) + ) else { + report.failureCategory = .panelAuthenticationRejected + return AskParityEvaluation(report: report, passed: false) + } + + do { + let panelConversation = try YouTubeAskParser.parseConversation(from: panelEnvelope) + report.panelChipCount = panelConversation.suggestions.count + let panelHasSummarySuggestion = panelConversation.suggestions.contains { suggestion in + isAskSummaryLabel(suggestion.label) + } + guard nextHasSummarySuggestion || panelHasSummarySuggestion else { + report.failureCategory = .summarySuggestionUnavailable + return AskParityEvaluation(report: report, passed: false) + } + } catch { + report.failureCategory = .panelParseFailure + return AskParityEvaluation(report: report, passed: false) + } + + report.failureCategory = .none + return AskParityEvaluation(report: report, passed: true) +} + +private func evaluateAskParityProfile( + _ profile: YouTubeAskRequestProfile, + videoID: String, + runtimeConfiguration: AskRuntimeWEBConfiguration?, + cookies: [HTTPCookie] +) async -> AskParityEvaluation { + let initialReport = AskParityReport( + profileName: askParityProfileName(profile), + failureCategory: .none + ) + let nextEvaluation = await evaluateAskParityNext( + profile: profile, + videoID: videoID, + runtimeConfiguration: runtimeConfiguration, + cookies: cookies, + initialReport: initialReport + ) + guard let bootstrap = nextEvaluation.bootstrap else { + return AskParityEvaluation(report: nextEvaluation.report, passed: false) + } + return await evaluateAskParityPanel( + profile: profile, + bootstrap: bootstrap, + runtimeConfiguration: runtimeConfiguration, + cookies: cookies, + nextReport: nextEvaluation.report + ) +} + +private func renderAskParityPreflightFailure(_ category: AskParityFailureCategory) { + AskParityReport(profileName: "preflight", failureCategory: category).render() +} + +private func selectAskParityProfile( + videoID: String, + cookies: [HTTPCookie], + evaluateAllProfiles: Bool = false +) async -> AskParitySelection? { + var firstPassingSelection: AskParitySelection? + let fixedProfiles = YouTubeAskRequestProfile.orderedParityProfiles( + runtimeClientVersion: YouTubeAskRequestProfile.productionClientVersion + ).prefix(2) + for profile in fixedProfiles { + let evaluation = await evaluateAskParityProfile( + profile, + videoID: videoID, + runtimeConfiguration: nil, + cookies: cookies + ) + evaluation.report.render() + if evaluation.passed { + let selection = AskParitySelection( + profile: profile, + runtimeConfiguration: nil, + cookies: cookies + ) + firstPassingSelection = firstPassingSelection ?? selection + if !evaluateAllProfiles { + return selection + } + } + print() + } + + let runtimeConfiguration: AskRuntimeWEBConfiguration + do { + runtimeConfiguration = try await resolveAskRuntimeWEBConfiguration(cookies: cookies) + } catch { + AskParityReport( + profileName: "runtime-web-all-proofs", + failureCategory: .runtimeConfigurationUnavailable + ).render() + return firstPassingSelection + } + let runtimeProfile = YouTubeAskRequestProfile.orderedParityProfiles( + runtimeClientVersion: runtimeConfiguration.clientVersion + )[2] + let evaluation = await evaluateAskParityProfile( + runtimeProfile, + videoID: videoID, + runtimeConfiguration: runtimeConfiguration, + cookies: cookies + ) + evaluation.report.render() + if evaluation.passed, firstPassingSelection == nil { + firstPassingSelection = AskParitySelection( + profile: runtimeProfile, + runtimeConfiguration: runtimeConfiguration, + cookies: cookies + ) + } + return firstPassingSelection +} + +func auditAskVideoRequestParity( + _ videoID: String, + hasUnsupportedOptions: Bool +) async { + guard isValidYouTubeVideoID(videoID) else { + renderAskParityPreflightFailure(.invalidVideoID) + return + } + if !youtubeMode { + activateYouTubeMode() + } + guard !hasUnsupportedOptions else { + renderAskParityPreflightFailure(.unsupportedOptions) + return + } + guard !forceUnauthenticatedRequests, + let cookies = loadCookiesFromAppBackup(), + getSAPISID(from: cookies) != nil, + buildCookieHeader(from: cookies) != nil + else { + renderAskParityPreflightFailure(.authenticationUnavailable) + return + } + guard !authUserOptionWasSpecified, globalBrandAccountId == nil else { + renderAskParityPreflightFailure(.unsupportedAccountSelection) + return + } + + _ = await selectAskParityProfile( + videoID: videoID, + cookies: cookies, + evaluateAllProfiles: true + ) +} + +private func askYouChatPanelContinuationTokens(in root: Any) -> [String] { + let maximumDepth = 80 + let maximumVisitedNodes = 100_000 + let maximumChildrenPerContainer = 2048 + var visitedNodes = 0 + var tokens: [String] = [] + var seenTokens: Set = [] + + func hasDirectYouChatSignal(_ dictionary: [String: Any]) -> Bool { + let exactMarkers: Set = [ + "PAai_companion", + "PAyouchat", + "engagement-panel-youchat", + ] + return dictionary.contains { key, value in + key.lowercased().contains("youchat") + || (value as? String).map(exactMarkers.contains) == true + } + } + + func walk( + _ value: Any, + depth: Int, + youChatRelevant: Bool, + insideSendUserQueryCommand: Bool + ) { + guard depth <= maximumDepth, visitedNodes < maximumVisitedNodes else { return } + visitedNodes += 1 + + if let dictionary = value as? [String: Any] { + let nestedYouChatRelevant = youChatRelevant || hasDirectYouChatSignal(dictionary) + if dictionary["request"] as? String == "CONTINUATION_REQUEST_TYPE_GET_PANEL", + let continuationValue = dictionary["token"] as? String, + !continuationValue.isEmpty, + nestedYouChatRelevant, + !insideSendUserQueryCommand, + seenTokens.insert(continuationValue).inserted + { + tokens.append(continuationValue) + } + for key in dictionary.keys.sorted().prefix(maximumChildrenPerContainer) { + guard let nestedValue = dictionary[key] else { continue } + walk( + nestedValue, + depth: depth + 1, + youChatRelevant: nestedYouChatRelevant, + insideSendUserQueryCommand: insideSendUserQueryCommand + || key == "sendUserQueryCommand" + ) + } + } else if let array = value as? [Any] { + for nestedValue in array.prefix(maximumChildrenPerContainer) { + walk( + nestedValue, + depth: depth + 1, + youChatRelevant: youChatRelevant, + insideSendUserQueryCommand: insideSendUserQueryCommand + ) + } + } + } + + walk( + root, + depth: 0, + youChatRelevant: false, + insideSendUserQueryCommand: false + ) + return tokens +} + +// MARK: - AskPanelSuggestion + +private struct AskPanelSuggestion { + let label: String + let continuation: String + let schemaLayers: [String] + + var isSummarySuggestion: Bool { + isAskSummaryLabel(self.label) + } +} + +private func isAskSummaryLabel(_ label: String) -> Bool { + let normalized = label + .folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current) + .lowercased() + .split(whereSeparator: \.isWhitespace) + .joined(separator: " ") + .trimmingCharacters(in: CharacterSet(charactersIn: " .!?…")) + let exactSummaryLabels: Set = [ + "summary", + "video summary", + "summary of this video", + "summarize", + "summarise", + "summarize video", + "summarise video", + "summarize this video", + "summarise this video", + "summarize the video", + "summarise the video", + "summarize key points", + "summarise key points", + "summarize the key points", + "summarise the key points", + "give me a summary", + "give me a summary of this video", + "provide a summary", + ] + return exactSummaryLabels.contains(normalized) +} + +private func safeAskSchemaKey(_ key: String) -> String { + guard !key.isEmpty, key.count <= 80, + key.unicodeScalars.allSatisfy({ scalar in + switch scalar.value { + case 36, 45, 46, 48 ... 57, 65 ... 90, 95, 97 ... 122: + true + default: + false + } + }) + else { + return "" + } + return key +} + +private func askSchemaValueDescription(_ value: Any) -> String { + if value is String { + return "string" + } + if value is Bool { + return "bool" + } + if value is NSNumber { + return "number" + } + if value is NSNull { + return "null" + } + if let dictionary = value as? [String: Any] { + let keys = dictionary.keys.map(safeAskSchemaKey).sorted().prefix(16) + return "object{\(keys.joined(separator: ","))\(dictionary.count > 16 ? ",…" : "")}" + } + if let array = value as? [Any] { + let elementDescription = array.first.map(askSchemaValueDescription) ?? "empty" + return "array<\(elementDescription)>" + } + return "other" +} + +private func askSchemaLayer( + via: String, + dictionary: [String: Any] +) -> String { + let fields = dictionary.keys.sorted().prefix(24).compactMap { key -> String? in + guard let value = dictionary[key] else { return nil } + return "\(safeAskSchemaKey(key)):\(askSchemaValueDescription(value))" + } + return "via \(via) -> {\(fields.joined(separator: ", "))\(dictionary.count > 24 ? ", …" : "")}" +} + +private func firstAskSchemaValue(forKey targetKey: String, in root: Any) -> Any? { + let maximumDepth = 80 + let maximumVisitedNodes = 100_000 + let maximumChildrenPerContainer = 2048 + var visitedNodes = 0 + + func find(_ value: Any, depth: Int) -> Any? { + guard depth <= maximumDepth, visitedNodes < maximumVisitedNodes else { return nil } + visitedNodes += 1 + if let dictionary = value as? [String: Any] { + if let match = dictionary[targetKey] { + return match + } + for nestedValue in dictionary.values.prefix(maximumChildrenPerContainer) { + if let match = find(nestedValue, depth: depth + 1) { + return match + } + } + } else if let array = value as? [Any] { + for nestedValue in array.prefix(maximumChildrenPerContainer) { + if let match = find(nestedValue, depth: depth + 1) { + return match + } + } + } + return nil + } + + return find(root, depth: 0) +} + +private func askSchemaTree(_ root: Any, maximumDepth: Int = 8) -> [String] { + let maximumVisitedNodes = 400 + let maximumChildrenPerContainer = 32 + var visitedNodes = 0 + var lines: [String] = [] + + func append(_ value: Any, path: String, depth: Int) { + guard depth <= maximumDepth, visitedNodes < maximumVisitedNodes else { return } + visitedNodes += 1 + if let dictionary = value as? [String: Any] { + lines.append("\(path): object") + for key in dictionary.keys.sorted().prefix(maximumChildrenPerContainer) { + guard let nestedValue = dictionary[key] else { continue } + append( + nestedValue, + path: "\(path).\(safeAskSchemaKey(key))", + depth: depth + 1 + ) + } + } else if let array = value as? [Any] { + lines.append("\(path): array") + for (index, nestedValue) in array.prefix(4).enumerated() { + append(nestedValue, path: "\(path)[\(index)]", depth: depth + 1) + } + } else { + lines.append("\(path): \(askSchemaValueDescription(value))") + } + } + + append(root, path: "$", depth: 0) + if visitedNodes >= maximumVisitedNodes { + lines.append("") + } + return lines +} + +private func askVisibleText(in value: Any) -> String? { + if let string = value as? String { + return string + } + guard let dictionary = value as? [String: Any] else { return nil } + if let content = dictionary["content"] as? String { + return content + } + if let simpleText = dictionary["simpleText"] as? String { + return simpleText + } + if let runs = dictionary["runs"] as? [[String: Any]] { + let text = runs.compactMap { $0["text"] as? String }.joined() + return text.isEmpty ? nil : text + } + return nil +} + +private func askPanelSuggestions(in root: Any) -> [AskPanelSuggestion] { + let maximumDepth = 80 + let maximumVisitedNodes = 100_000 + let maximumChildrenPerContainer = 2048 + var visitedNodes = 0 + var suggestions: [AskPanelSuggestion] = [] + var seenContinuations: Set = [] + + func collectSuggestions( + from viewModel: [String: Any], + context: [String] + ) { + guard let chipsData = viewModel["chipsData"] as? [String: Any], + let chipItems = chipsData["chipData"] as? [[String: Any]] + else { + return + } + + let viewModelContext = Array( + (context + [askSchemaLayer(via: "youChatItemViewModel", dictionary: viewModel)]) + .suffix(8) + ) + let chipsContext = Array( + (viewModelContext + [askSchemaLayer(via: "chipsData", dictionary: chipsData)]) + .suffix(8) + ) + for (index, chip) in chipItems.prefix(maximumChildrenPerContainer).enumerated() { + guard let continuation = chip["continuation"] as? String, + !continuation.isEmpty, + let label = chip["text"].flatMap(askVisibleText), + !label.isEmpty, + label.count <= 200, + seenContinuations.insert(continuation).inserted + else { + continue + } + let chipContext = Array( + (chipsContext + [askSchemaLayer(via: "[\(index)]", dictionary: chip)]) + .suffix(8) + ) + suggestions.append(AskPanelSuggestion( + label: label, + continuation: continuation, + schemaLayers: chipContext + )) + } + } + + func walk(_ value: Any, depth: Int, via: String, context: [String]) { + guard depth <= maximumDepth, visitedNodes < maximumVisitedNodes else { return } + visitedNodes += 1 + + if let dictionary = value as? [String: Any] { + let nextContext = Array( + (context + [askSchemaLayer(via: via, dictionary: dictionary)]).suffix(8) + ) + if let viewModel = dictionary["youChatItemViewModel"] as? [String: Any] { + collectSuggestions(from: viewModel, context: nextContext) + } + for (key, nestedValue) in dictionary.sorted(by: { $0.key < $1.key }) + .prefix(maximumChildrenPerContainer) + { + walk( + nestedValue, + depth: depth + 1, + via: safeAskSchemaKey(key), + context: nextContext + ) + } + } else if let array = value as? [Any] { + for (index, nestedValue) in array.prefix(maximumChildrenPerContainer).enumerated() { + walk(nestedValue, depth: depth + 1, via: "[\(index)]", context: context) + } + } + } + + walk(root, depth: 0, via: "$", context: []) + return suggestions +} + +private func replacingAskOutputMatches( + in value: String, + pattern: String, + replacement: String +) -> String { + guard let expression = try? NSRegularExpression(pattern: pattern) else { return value } + let range = NSRange(value.startIndex ..< value.endIndex, in: value) + return expression.stringByReplacingMatches( + in: value, + options: [], + range: range, + withTemplate: replacement + ) +} + +private func sanitizedAskVisibleOutput(_ value: String, maximumCharacters: Int = 16000) -> String { + let bidiControls: Set = [ + 0x061C, 0x200E, 0x200F, 0x202A, 0x202B, 0x202C, 0x202D, 0x202E, + 0x2066, 0x2067, 0x2068, 0x2069, + ] + var sanitized = String.UnicodeScalarView() + sanitized.reserveCapacity(value.unicodeScalars.count) + for scalar in value.unicodeScalars { + let scalarValue = scalar.value + if bidiControls.contains(scalarValue) { + continue + } + if CharacterSet.controlCharacters.contains(scalar), scalar != "\n", scalar != "\t" { + continue + } + sanitized.append(scalar) + } + + var result = String(sanitized) + result = replacingAskOutputMatches( + in: result, + pattern: "\\u001B\\[[0-?]*[ -/]*[@-~]", + replacement: "" + ) + result = replacingAskOutputMatches( + in: result, + pattern: "(?i)https?://[^\\s)>]+", + replacement: "" + ) + result = replacingAskOutputMatches( + in: result, + pattern: "(?" + ) + result = result.trimmingCharacters(in: .whitespacesAndNewlines) + if result.count > maximumCharacters { + result = String(result.prefix(maximumCharacters)) + "\n[…output truncated…]" + } + return result +} + +private func makeSelectedAskWireRequest( + endpoint: String, + bodyData: Data, + selection: AskParitySelection +) async throws -> APIWireResponse { + try await makeAskParityWireRequest( + endpoint: endpoint, + bodyData: bodyData, + profile: selection.profile, + runtimeConfiguration: selection.runtimeConfiguration, + cookies: selection.cookies + ) +} + +private func confirmedSignedInAskEnvelope( + from response: APIWireResponse, + operation: String +) throws -> YouTubeAskWireEnvelope { + let envelope = try YouTubeAskWireDecoder.decode(response.data) + guard askParityHasConfirmedSignedInState( + askParityServerLoggedOutState(in: envelope) + ) else { + throw NSError( + domain: "APIExplorer", + code: -1, + userInfo: [ + NSLocalizedDescriptionKey: + "YouTube did not explicitly confirm the \(operation) response as signed in", + ] + ) + } + return envelope +} + +private func makeAskSuggestionRequest( + _ suggestion: YouTubeAskParsedSuggestion, + selection: AskParitySelection +) async throws -> APIWireResponse { + let currentUnixTimeMilliseconds = Int64(Date().timeIntervalSince1970 * 1000) + let clientMessageId = "youchat-\(currentUnixTimeMilliseconds)" + let bodyData = try YouTubeAskRequestBuilder.makeDirectChipBody( + command: suggestion.command, + clientMessageID: clientMessageId + ) + let response = try await makeSelectedAskWireRequest( + endpoint: "get_panel", + bodyData: bodyData, + selection: selection + ) + guard (200 ... 299).contains(response.statusCode) else { + throw NSError( + domain: "APIExplorer", + code: response.statusCode, + userInfo: [ + NSLocalizedDescriptionKey: + "Ask panel request returned HTTP \(response.statusCode)", + ] + ) + } + _ = try confirmedSignedInAskEnvelope( + from: response, + operation: "Ask suggestion" + ) + return response +} + +private func makeAskSummaryRequest( + videoId: String, + selection: AskParitySelection +) async throws -> APIWireResponse { + let nextBody = try JSONSerialization.data(withJSONObject: ["videoId": videoId]) + let nextResponse = try await makeSelectedAskWireRequest( + endpoint: "next", + bodyData: nextBody, + selection: selection + ) + guard (200 ... 299).contains(nextResponse.statusCode) else { + throw NSError( + domain: "APIExplorer", + code: nextResponse.statusCode, + userInfo: [ + NSLocalizedDescriptionKey: + "Watch bootstrap request returned HTTP \(nextResponse.statusCode)", + ] + ) + } + let nextEnvelope = try confirmedSignedInAskEnvelope( + from: nextResponse, + operation: "watch bootstrap" + ) + + guard let bootstrap = try YouTubeAskParser.parseBootstrap(from: nextEnvelope) else { + throw NSError( + domain: "APIExplorer", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "No eligible Ask bootstrap was available"] + ) + } + let watchSuggestions = bootstrap.suggestions + if let summarySuggestion = watchSuggestions.first(where: { isAskSummaryLabel($0.label) }) { + return try await makeAskSuggestionRequest( + summarySuggestion, + selection: selection + ) + } + + guard let panelCommand = bootstrap.panelCommand else { + throw NSError( + domain: "APIExplorer", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "No server-issued Ask panel continuation was available"] + ) + } + + let panelBody = YouTubeAskRequestBuilder.makePanelBootstrapBody(command: panelCommand) + let panelResponse = try await makeSelectedAskWireRequest( + endpoint: "get_panel", + bodyData: panelBody, + selection: selection + ) + guard (200 ... 299).contains(panelResponse.statusCode) else { + throw NSError( + domain: "APIExplorer", + code: panelResponse.statusCode, + userInfo: [ + NSLocalizedDescriptionKey: + "Ask panel bootstrap returned HTTP \(panelResponse.statusCode)", + ] + ) + } + let panelEnvelope = try confirmedSignedInAskEnvelope( + from: panelResponse, + operation: "Ask panel bootstrap" + ) + let panelConversation = try YouTubeAskParser.parseConversation(from: panelEnvelope) + let panelSuggestions = panelConversation.suggestions + guard let summarySuggestion = panelSuggestions.first(where: { isAskSummaryLabel($0.label) }) else { + throw NSError( + domain: "APIExplorer", + code: -1, + userInfo: [ + NSLocalizedDescriptionKey: + "No recognizable server-issued summary suggestion was found (watch candidates: \(watchSuggestions.count), panel candidates: \(panelSuggestions.count))", + ] + ) + } + + return try await makeAskSuggestionRequest( + summarySuggestion, + selection: selection + ) +} + +private func printAskLiveWireSummary( + _ response: APIWireResponse, + verbose: Bool +) { + print(" HTTP \(response.statusCode), \(response.data.count) response bytes") + if verbose { + print(wireResponseAuditSummary( + data: response.data, + statusCode: response.statusCode, + contentType: response.contentType + )) + } +} + +// MARK: - AskLiveAnswer + +private struct AskLiveAnswer { + let text: String + let wasTruncated: Bool +} + +private func renderedAskLiveMessage(_ message: YouTubeAskParsedMessage) -> String { + let visibleText = sanitizedAskVisibleOutput(message.text) + guard message.wasTruncated else { return visibleText } + return visibleText + "\n[…answer truncated by safety limit…]" +} + +private func runAskLiveChat( + videoId: String, + selection: AskParitySelection, + includeFollowUp: Bool, + verbose: Bool +) async throws -> AskLiveAnswer { + let response = try await makeAskSummaryRequest( + videoId: videoId, + selection: selection + ) + printAskLiveWireSummary(response, verbose: verbose) + + let conversation = try YouTubeAskParser.parseConversation( + from: YouTubeAskWireDecoder.decode(response.data) + ) + guard let answerMessage = conversation.messages.first else { + throw NSError( + domain: "APIExplorer", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "No generated summary text was found"] + ) + } + print("\nSummary:\n\(renderedAskLiveMessage(answerMessage))") + + let answer = AskLiveAnswer( + text: answerMessage.text, + wasTruncated: answerMessage.wasTruncated + ) + guard includeFollowUp else { return answer } + let followUps = conversation.suggestions + .filter { !isAskSummaryLabel($0.label) } + guard let followUp = followUps.first else { + throw NSError( + domain: "APIExplorer", + code: -1, + userInfo: [ + NSLocalizedDescriptionKey: + "The summary response did not expose a follow-up suggestion", + ] + ) + } + print( + "\nFollow-up question:\n\(sanitizedAskVisibleOutput(followUp.label, maximumCharacters: 500))" + ) + let followUpResponse = try await makeAskSuggestionRequest( + followUp, + selection: selection + ) + printAskLiveWireSummary(followUpResponse, verbose: verbose) + + let followUpConversation = try YouTubeAskParser.parseConversation( + from: YouTubeAskWireDecoder.decode(followUpResponse.data) + ) + guard let followUpAnswer = followUpConversation.messages.first else { + throw NSError( + domain: "APIExplorer", + code: -1, + userInfo: [ + NSLocalizedDescriptionKey: + "No generated follow-up answer was found", + ] + ) + } + print("\nFollow-up answer:\n\(renderedAskLiveMessage(followUpAnswer))") + return answer +} + +func liveTestAskVideo( + _ videoId: String, + freshChatCount: Int, + includeFollowUp: Bool, + verbose: Bool +) async { + guard isValidYouTubeVideoID(videoId) else { + print("❌ Invalid YouTube video ID") + return + } + if !youtubeMode { + activateYouTubeMode() + } + guard !forceUnauthenticatedRequests else { + print("❌ ask-video-live-test requires authentication; remove --guest/--no-auth") + return + } + guard !authUserOptionWasSpecified, globalBrandAccountId == nil else { + print("❌ ask-video-live-test does not support --authuser or --brand") + return + } + guard let cookies = loadCookiesFromAppBackup(), + getSAPISID(from: cookies) != nil, + buildCookieHeader(from: cookies) != nil + else { + print("❌ No usable Kaset cookie export is available") + return + } + guard (1 ... 3).contains(freshChatCount) else { + print("❌ --fresh-chats must be between 1 and 3") + return + } + + print("🧪 Ask Gemini live summary test") + print("===============================\n") + print("Video ID: \(videoId)") + print("Fresh chats requested: \(freshChatCount)") + print("Server-issued follow-up: \(includeFollowUp ? "enabled" : "disabled")") + print("Safety: only the server-issued summary suggestion is replayed; opaque state stays in memory") + print("\nRead-only request-profile validation:") + + guard let selection = await selectAskParityProfile( + videoID: videoId, + cookies: cookies + ) else { + print("\n❌ No request profile passed authenticated Ask parity validation") + return + } + print("\nSelected request profile: \(selection.profileName)") + print("The selected profile and runtime bundle will be reused for every live Ask request") + + var summaryAnswers: [AskLiveAnswer] = [] + for chatIndex in 1 ... freshChatCount { + print("\nChat \(chatIndex): server-issued summary") + do { + let answer = try await runAskLiveChat( + videoId: videoId, + selection: selection, + includeFollowUp: includeFollowUp, + verbose: verbose + ) + summaryAnswers.append(answer) + } catch { + print("❌ Chat \(chatIndex) failed: \(error.localizedDescription)") + return + } + } + + if summaryAnswers.count > 1 { + print("\nFresh-chat validation:") + print(" Successful independent bootstraps: \(summaryAnswers.count)") + if summaryAnswers.contains(where: \.wasTruncated) { + print(" Generated summaries exactly equal: not evaluated (truncated answer)") + } else { + let firstAnswer = summaryAnswers[0].text + let allEqual = summaryAnswers.dropFirst().allSatisfy { $0.text == firstAnswer } + print(" Generated summaries exactly equal: \(allEqual ? "yes" : "no")") + } + print(" Conversation identifiers: not displayed or inferred") + } +} + +func auditAskVideo(_ videoId: String, verbose: Bool) async { + guard isValidYouTubeVideoID(videoId) else { + print("❌ Invalid YouTube video ID") + return + } + + if !youtubeMode { + activateYouTubeMode() + } + guard !authUserOptionWasSpecified, globalBrandAccountId == nil else { + print("❌ ask-video-audit does not support --authuser or --brand") + print(" The watch-page GET cannot safely guarantee the same selected identity as API probes.") + return + } + + let authenticated = !forceUnauthenticatedRequests && hasUsableAuthMaterial() + print("🔬 Ask Gemini / YouChat API audit") + print("=================================\n") + print("Video ID: \(videoId)") + print("Auth material: \(authenticated ? "✅ available (server validity checked below)" : "⚠️ unavailable")") + print("Safety: read-only probes; no prompt is submitted; opaque values remain hidden") + if verbose { + print("Verbose mode remains schema-only; raw response values are never emitted") + } + print() + + do { + print("1) Watch-next bootstrap (`next`)") + let nextResponse = try await makeWireRequest( + endpoint: "next", + body: ["videoId": videoId], + authenticated: authenticated + ) + print(wireResponseAuditSummary( + data: nextResponse.data, + statusCode: nextResponse.statusCode, + contentType: nextResponse.contentType + )) + let nextEnvelope = try? YouTubeAskWireDecoder.decode(nextResponse.data) + let parsedBootstrap = nextEnvelope.flatMap { envelope in + try? YouTubeAskParser.parseBootstrap(from: envelope) + } ?? nil + let nextJSON = try? JSONSerialization.jsonObject(with: nextResponse.data) as? [String: Any] + if let nextEnvelope, + let loggedOut = askParityServerLoggedOutState(in: nextEnvelope) + { + print(" Server session: \(loggedOut ? "❌ signed out" : "✅ signed in")") + if loggedOut, authenticated { + print(" ⚠️ The available cookie export was not accepted as a signed-in YouTube WEB session.") + } + } + + let nextSuggestions = parsedBootstrap?.suggestions ?? [] + print(" Server-issued query suggestions: \(nextSuggestions.count)") + if nextSuggestions.contains(where: { isAskSummaryLabel($0.label) }) { + print(" Summary suggestion: ✅ available") + if verbose, let nextJSON { + let schemaSuggestions = askPanelSuggestions(in: nextJSON) + if let summarySuggestion = schemaSuggestions.first(where: \.isSummarySuggestion) { + print(" Summary suggestion schema (values hidden):") + for layer in summarySuggestion.schemaLayers { + print(" - \(layer)") + } + } + print(" Summary chip schema tree (values hidden):") + if let summaryChip = firstAskSchemaValue( + forKey: "chipData", + in: nextJSON + ) { + for line in askSchemaTree(summaryChip, maximumDepth: 7) { + print(" - \(line)") + } + } + for commandKey in ["sendUserQueryCommand", "formDataDecoratorCommand"] { + print(" \(commandKey) schema (values hidden):") + if let command = firstAskSchemaValue(forKey: commandKey, in: nextJSON) { + for line in askSchemaTree(command, maximumDepth: 10) { + print(" - \(line)") + } + } else { + print(" - unavailable") + } + } + } + } else { + print(" Summary suggestion: unavailable") + } + + print("\n2) Ask panel bootstrap (`get_panel`, server-issued continuation)") + if let panelCommand = parsedBootstrap?.panelCommand { + let panelBodyData = YouTubeAskRequestBuilder.makePanelBootstrapBody( + command: panelCommand + ) + guard let panelBody = try JSONSerialization.jsonObject(with: panelBodyData) as? [String: Any] else { + throw NSError( + domain: "APIExplorer", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Could not construct the Ask panel bootstrap body"] + ) + } + let panelResponse = try await makeWireRequest( + endpoint: "get_panel", + body: panelBody, + authenticated: authenticated + ) + print(wireResponseAuditSummary( + data: panelResponse.data, + statusCode: panelResponse.statusCode, + contentType: panelResponse.contentType + )) + if let panelEnvelope = try? YouTubeAskWireDecoder.decode(panelResponse.data), + let panelConversation = try? YouTubeAskParser.parseConversation(from: panelEnvelope) + { + let suggestions = panelConversation.suggestions + print(" Server-issued panel suggestions: \(suggestions.count)") + print(" Panel summary suggestion: \(suggestions.contains(where: { isAskSummaryLabel($0.label) }) ? "✅ available" : "unavailable")") + } + } else { + print(" No server-issued Ask panel continuation was available.") + } + + print("\n3) Combined watch bootstrap (`get_watch`)") + let getWatchResponse = try await makeWireRequest( + endpoint: "get_watch", + body: [ + "playerRequest": ["videoId": videoId], + "watchNextRequest": ["videoId": videoId], + ], + authenticated: authenticated + ) + print(wireResponseAuditSummary( + data: getWatchResponse.data, + statusCode: getWatchResponse.statusCode, + contentType: getWatchResponse.contentType + )) + + print("\n4) AI answer transport capability (`get_answer`, empty read-only probe)") + let answerResponse = try await makeWireRequest( + endpoint: "get_answer", + body: [:], + authenticated: authenticated + ) + print(wireResponseAuditSummary( + data: answerResponse.data, + statusCode: answerResponse.statusCode, + contentType: answerResponse.contentType + )) + + print("\n5) Current web frontend capability markers") + var watchComponents = URLComponents(string: "https://www.youtube.com/watch") + watchComponents?.queryItems = [URLQueryItem(name: "v", value: videoId)] + guard let watchURL = watchComponents?.url else { + print(" ❌ Could not construct watch URL") + return + } + let pageResponse = try await fetchYouTubeWebResource( + watchURL, authenticated: authenticated + ) + guard (200 ... 299).contains(pageResponse.statusCode), + let pageMediaType = normalizedMediaType(pageResponse.contentType), + ["text/html", "application/xhtml+xml"].contains(pageMediaType), + let html = String(data: pageResponse.data, encoding: .utf8) + else { + print(" ❌ Watch page source unavailable or not HTML (HTTP \(pageResponse.statusCode))") + return + } + + var mainJavaScript: String? + if let scriptURL = extractYouTubeMainAppJavaScriptURL( + from: html, baseURL: watchURL + ) { + do { + let scriptResponse = try await fetchYouTubeWebResource( + scriptURL, authenticated: false + ) + if (200 ... 299).contains(scriptResponse.statusCode), + let scriptMediaType = normalizedMediaType(scriptResponse.contentType), + [ + "application/ecmascript", "application/javascript", + "text/ecmascript", "text/javascript", + ].contains(scriptMediaType), + let script = String(data: scriptResponse.data, encoding: .utf8) + { + mainJavaScript = script + print( + " Watch page HTTP \(pageResponse.statusCode), main app HTTP \(scriptResponse.statusCode)" + ) + } else { + print(" Watch page HTTP \(pageResponse.statusCode); main app source unavailable") + } + } catch let error as ResponseSizeLimitError { + print(" Main app source skipped: \(error.localizedDescription)") + } catch { + print(" Watch page HTTP \(pageResponse.statusCode); main app source unavailable") + } + } else { + print(" Watch page HTTP \(pageResponse.statusCode); main app asset not found") + } + print(youtubeAIFrontendCapabilitySummary( + html: html, mainJavaScript: mainJavaScript + )) + if verbose, let mainJavaScript { + print() + print(youtubeAIFrontendFlowDebugSummary(mainJavaScript: mainJavaScript)) + } + } catch { + print("❌ Audit failed: \(error.localizedDescription)") + } +} + +// MARK: - AskVideoAuditSafety + +private enum AskVideoAuditSafety { + static func canonicalFieldName(_ value: String) -> String { + value.lowercased().unicodeScalars.reduce(into: "") { result, scalar in + if (48 ... 57).contains(scalar.value) || (97 ... 122).contains(scalar.value) { + result.unicodeScalars.append(scalar) + } + } + } + + static func safeSchemaName(_ key: String, maximumLength: Int = 100) -> String? { + guard !key.isEmpty, key.count <= maximumLength else { return nil } + let allowed = key.unicodeScalars.allSatisfy { scalar in + switch scalar.value { + case 36, 45, 46, 48 ... 57, 65 ... 90, 95, 97 ... 122: + true + default: + false + } + } + return allowed ? key : nil + } + + private static let fixedResponseKeys: Set = [ + "apiUrl", "clickTrackingParams", "clientMessageId", "command", "commandMetadata", + "content", "contents", "continuation", "conversationId", "currentVideoEndpoint", + "engagementPanels", "formDataDecoratorCommand", "frameworkUpdates", "getAnswerCommand", + "globalConfiguration", "header", "identifier", "inputComposerFormData", "label", + "onResponseReceivedCommand", "onResponseReceivedCommands", "pageContext", "panelIdentifier", + "params", "placeholder", "placeholderText", "playerOverlays", "playerResponse", + "previousClientMessageId", "responseContext", "responseType", "runs", "sendUserQueryCommand", + "simpleText", "subStreamResponseCompleted", "tag", "targetId", "text", "timedCommand", + "title", "token", "topbar", "trackingParams", "updateConversationIdCommand", + "videoSummaryContentViewModel", "videoSummaryParagraphViewModel", "watchNextResponse", + "webCommandMetadata", "wireChunks", "youchatPendingResponseEntity", + ] + + static func safeResponseKey(_ key: String) -> String? { + if self.fixedResponseKeys.contains(key) { + return key + } + if key.hasSuffix("Renderer") { + return "" + } + if key.hasSuffix("ViewModel") { + return "" + } + if key.hasSuffix("Command") { + return "" + } + if key.hasSuffix("Endpoint") { + return "" + } + if key.hasSuffix("Entity") { + return "" + } + return nil + } + + static func appending(key: String, to path: String) -> String { + if let safeKey = safeResponseKey(key), self.isSimplePathKey(safeKey) { + return "\(path).\(safeKey)" + } + let safeKey = Self.safeResponseKey(key) ?? "" + return "\(path)[\(String(reflecting: safeKey))]" + } + + static func safeYouTubeInnerTubePath(from rawValue: String) -> String? { + guard !rawValue.isEmpty, rawValue.count <= 2048, + let components = URLComponents(string: rawValue) + else { + return nil + } + if components.scheme != nil || components.host != nil { + guard components.scheme?.lowercased() == "https", + let host = components.host?.lowercased(), + Self.isYouTubeHost(host) + else { + return nil + } + } else { + guard rawValue.hasPrefix("/"), !rawValue.hasPrefix("//") else { return nil } + } + let path = components.percentEncodedPath + guard !path.contains("%"), + path.hasPrefix("/youtubei/v1/"), + path.count <= 256 + else { + return nil + } + let segments = path.split(separator: "/", omittingEmptySubsequences: true) + guard (3 ... 6).contains(segments.count), + segments.allSatisfy({ segment in + !segment.isEmpty && segment.count <= 64 + && segment.unicodeScalars.allSatisfy { scalar in + switch scalar.value { + case 45, 48 ... 57, 65 ... 90, 95, 97 ... 122: + true + default: + false + } + } + }) + else { + return nil + } + return path + } + + static func isYouTubeHost(_ host: String) -> Bool { + host == "youtube.com" || host.hasSuffix(".youtube.com") + } + + static func isOpaqueField(key: String, value: Any) -> Bool { + let canonical = Self.canonicalFieldName(key) + if canonical.contains("token") || canonical.contains("params") + || canonical == "conversationid" || canonical.hasSuffix("conversationid") + || canonical == "continuation" || canonical.hasSuffix("continuationtoken") + || canonical.contains("tracking") || canonical.contains("visitor") + || canonical.contains("datasync") || canonical.contains("nonce") + || canonical.contains("serialized") || canonical.contains("integrity") + || canonical == "clientmessageid" || canonical == "previousclientmessageid" + || canonical == "pendingsuggestedqueryidentifier" || canonical == "pagecontext" + || canonical.contains("authorization") || canonical.contains("cookie") + { + return true + } + return canonical.contains("continuation") && value is String + } + + static func isConversationIdentifierField(_ field: String) -> Bool { + let canonical = Self.canonicalFieldName(field) + return canonical == "conversationid" || canonical.hasSuffix("conversationid") + } + + static func trimASCIIWhitespace(_ bytes: [UInt8]) -> [UInt8] { + var lowerBound = 0 + var upperBound = bytes.count + while lowerBound < upperBound, Self.isASCIIWhitespace(bytes[lowerBound]) { + lowerBound += 1 + } + while upperBound > lowerBound, Self.isASCIIWhitespace(bytes[upperBound - 1]) { + upperBound -= 1 + } + return Array(bytes[lowerBound ..< upperBound]) + } + + static func skipASCIIWhitespace(in bytes: [UInt8], index: inout Int) { + while index < bytes.count, self.isASCIIWhitespace(bytes[index]) { + index += 1 + } + } + + static func isASCIIWhitespace(_ byte: UInt8) -> Bool { + byte == 9 || byte == 10 || byte == 13 || byte == 32 + } + + private static func isSimplePathKey(_ key: String) -> Bool { + guard let first = key.unicodeScalars.first, + key.count <= 80, + first.value == 36 || first.value == 95 || (65 ... 90).contains(first.value) + || (97 ... 122).contains(first.value) + else { + return false + } + return key.unicodeScalars.dropFirst().allSatisfy { scalar in + scalar.value == 36 || scalar.value == 95 || (48 ... 57).contains(scalar.value) + || (65 ... 90).contains(scalar.value) || (97 ... 122).contains(scalar.value) + } + } +} + +// MARK: - AskVideoResponseAuditor + +private struct AskVideoResponseAuditor { + struct Observation { + var count = 0 + var samples: [String] = [] + + mutating func record(_ sample: String, limit: Int = 5) { + self.count += 1 + if self.samples.count < limit, !self.samples.contains(sample) { + self.samples.append(sample) + } + } + } + + struct Report { + var markers: [String: Observation] = [:] + var endpoints: [String: Observation] = [:] + var opaqueFields: [String: Observation] = [:] + var visitedNodes = 0 + var truncated = false + + var hasAIEvidence: Bool { + self.truncated || !self.markers.isEmpty || !self.endpoints.isEmpty + || !self.opaqueFields.isEmpty + } + + func rendered() -> String { + var lines = [ + "Ask Gemini / YouChat response audit", + " Traversed nodes: \(self.visitedNodes)\(self.truncated ? " (bounded; traversal truncated)" : "")", + ] + Self.append(self.markers, title: "AI identifiers and commands", to: &lines) + Self.append(self.endpoints, title: "AI-related YouTube InnerTube apiUrl paths", to: &lines) + Self.append(self.opaqueFields, title: "Opaque AI request fields (values redacted)", to: &lines) + return lines.joined(separator: "\n") + } + + private static func append( + _ observations: [String: Observation], + title: String, + to lines: inout [String] + ) { + lines.append(" \(title):") + guard !observations.isEmpty else { + lines.append(" - none detected") + return + } + for (name, observation) in observations.sorted(by: { $0.key < $1.key }) { + lines.append(" - \(name): \(observation.count) occurrence(s)") + if !observation.samples.isEmpty { + lines.append(" samples: \(observation.samples.joined(separator: ", "))") + } + } + } + } + + private static let maximumDepth = 80 + private static let maximumVisitedNodes = 100_000 + private static let maximumChildrenPerContainer = 2048 + private static let maximumObservationKinds = 128 + private static let markers: Set = [ + "CONTINUATION_REQUEST_TYPE_GET_PANEL", "PAai_companion", "PAyouchat", + "engagement-panel-youchat", "formDataDecoratorCommand", "getAnswerCommand", + "inputComposerFormData", "sendUserQueryCommand", "updateConversationIdCommand", + "videoSummaryContentViewModel", "videoSummaryParagraphViewModel", + "youchatPendingResponseEntity", + ] + private static let aiEndpoints: Set = [ + "/youtubei/v1/get_answer", "/youtubei/v1/get_panel", + "/youtubei/v1/get_watch", "/youtubei/v1/streaming_panel", + ] + + private var report = Report() + mutating func audit(_ response: [String: Any]) -> Report { + self.walk(response, path: "$", depth: 0, aiRelevant: false) + return self.report + } + + private mutating func walk( + _ value: Any, + path: String, + depth: Int, + aiRelevant: Bool + ) { + guard depth <= Self.maximumDepth, self.report.visitedNodes < Self.maximumVisitedNodes else { + self.report.truncated = true + return + } + self.report.visitedNodes += 1 + + if let dictionary = value as? [String: Any] { + let dictionaryIsAIRelevant = aiRelevant || Self.hasDirectAISignal(dictionary) + let keys = dictionary.keys.prefix(Self.maximumChildrenPerContainer).sorted() + if dictionary.count > Self.maximumChildrenPerContainer { + self.report.truncated = true + } + for key in keys { + guard self.report.visitedNodes < Self.maximumVisitedNodes else { + self.report.truncated = true + break + } + guard let nestedValue = dictionary[key] else { continue } + let nestedPath = AskVideoAuditSafety.appending(key: key, to: path) + let keyMarker = Self.markerLabel(for: key) + let valueMarker = (nestedValue as? String).flatMap(Self.fixedMarkerLabel) + let nestedIsAIRelevant = dictionaryIsAIRelevant || keyMarker != nil || valueMarker != nil + + if let keyMarker { + if !Self.record(keyMarker, sample: "\(nestedPath) [key]", in: &self.report.markers) { + self.report.truncated = true + } + } + if let valueMarker { + if !Self.record(valueMarker, sample: "\(nestedPath) [value]", in: &self.report.markers) { + self.report.truncated = true + } + } + if key == "apiUrl", let rawURL = nestedValue as? String, + let endpoint = AskVideoAuditSafety.safeYouTubeInnerTubePath(from: rawURL), + Self.aiEndpoints.contains(endpoint) + { + if !Self.record(endpoint, sample: nestedPath, in: &self.report.endpoints) { + self.report.truncated = true + } + } + + let fieldIsOpaque = AskVideoAuditSafety.isOpaqueField(key: key, value: nestedValue) + if fieldIsOpaque, + nestedIsAIRelevant || AskVideoAuditSafety.isConversationIdentifierField(key) + { + let field = AskVideoAuditSafety.safeResponseKey(key) ?? "" + let length = (nestedValue as? String).map { "characters=\($0.count)" } ?? "present" + if !Self.record(field, sample: "\(nestedPath) (\(length))", in: &self.report.opaqueFields) { + self.report.truncated = true + } + } + if nestedIsAIRelevant, !fieldIsOpaque, Self.isAITextField(key), + let text = nestedValue as? String + { + let field = AskVideoAuditSafety.safeResponseKey(key) ?? "" + if !Self.record( + "\(field) text", + sample: "\(nestedPath) (characters=\(text.count), value hidden)", + in: &self.report.opaqueFields + ) { + self.report.truncated = true + } + } + self.walk( + nestedValue, + path: nestedPath, + depth: depth + 1, + aiRelevant: nestedIsAIRelevant + ) + } + } else if let array = value as? [Any] { + if array.count > Self.maximumChildrenPerContainer { + self.report.truncated = true + } + for (index, nestedValue) in array.prefix(Self.maximumChildrenPerContainer).enumerated() { + guard self.report.visitedNodes < Self.maximumVisitedNodes else { + self.report.truncated = true + break + } + self.walk( + nestedValue, + path: "\(path)[\(index)]", + depth: depth + 1, + aiRelevant: aiRelevant + ) + } + } + } + + @discardableResult + private static func record( + _ name: String, + sample: String, + in observations: inout [String: Observation] + ) -> Bool { + guard observations[name] != nil || observations.count < self.maximumObservationKinds else { + return false + } + var observation = observations[name] ?? Observation() + observation.record(sample) + observations[name] = observation + return true + } + + private static func markerLabel(for candidate: String) -> String? { + if self.markers.contains(candidate) { + return candidate + } + let canonical = AskVideoAuditSafety.canonicalFieldName(candidate) + let schemaSuffixes = ["renderer", "viewmodel", "command", "endpoint", "entity"] + guard candidate.count <= 128, + candidate.lowercased().contains("youchat"), + schemaSuffixes.contains(where: canonical.hasSuffix), + AskVideoAuditSafety.safeSchemaName(candidate, maximumLength: 128) != nil + else { + return nil + } + return "otherYouChatSchemaMarker" + } + + private static func fixedMarkerLabel(for candidate: String) -> String? { + self.markers.contains(candidate) ? candidate : nil + } + + private static func hasDirectAISignal(_ dictionary: [String: Any]) -> Bool { + dictionary.prefix(self.maximumChildrenPerContainer).contains { key, value in + if Self.markerLabel(for: key) != nil + || AskVideoAuditSafety.isConversationIdentifierField(key) + || (value as? String).flatMap(Self.markerLabel) != nil + { + return true + } + guard key == "apiUrl", let rawURL = value as? String, + let endpoint = AskVideoAuditSafety.safeYouTubeInnerTubePath(from: rawURL) + else { + return false + } + return Self.aiEndpoints.contains(endpoint) + } + } + + private static func isAITextField(_ key: String) -> Bool { + let fields: Set = [ + "accessibilitylabel", "content", "description", "header", "label", "message", + "placeholder", "placeholdertext", "simpletext", "text", "title", + ] + return fields.contains(AskVideoAuditSafety.canonicalFieldName(key)) + } +} + +// MARK: - WireResponseAuditor + +private enum WireResponseAuditor { + private struct ParsedChunks { + let chunks: [Any] + let truncated: Bool + let limitDescription: String? + } + + private static let maximumAuditedBytes = 32 * 1024 * 1024 + private static let maximumFrameBytes = 4 * 1024 * 1024 + private static let maximumFrames = 256 + private static let maximumChildrenPerContainer = 2048 + private static let maximumRendererKinds = 256 + private static let maximumTopLevelKeys = 30 + private static let maximumRendererTypes = 20 + + static func summary(data: Data, statusCode: Int, contentType: String?) -> String { + guard data.count <= self.maximumAuditedBytes else { + return [ + "Wire response audit", + " HTTP status: \(statusCode)", + " Content type: \(self.safeMediaType(contentType))", + " Byte count: \(data.count)", + " Classification: response exceeds 32 MiB audit limit", + " Response bytes: not parsed or displayed", + ].joined(separator: "\n") + } + let prepared = Self.prepare(data) + let decoded = Self.decode(data: prepared.data, hadXSSI: prepared.hadXSSI) + var lines = [ + "Wire response audit", + " HTTP status: \(statusCode)", + " Content type: \(Self.safeMediaType(contentType))", + " Byte count: \(data.count)", + " Classification: \(decoded.classification)", + " JSON chunk count: \(decoded.chunks.count)", + ] + guard !decoded.chunks.isEmpty else { + lines += [" Top-level keys: unavailable", " Response bytes: not displayed"] + return lines.joined(separator: "\n") + } + + let topLevelKeyResult = Self.topLevelKeys(in: decoded.chunks) + let topLevelKeys = topLevelKeyResult.keys + let shownKeys = topLevelKeys.prefix(Self.maximumTopLevelKeys) + let remainder = topLevelKeys.count - shownKeys.count + lines.append(topLevelKeys.isEmpty + ? " Top-level keys: none (array or scalar root)" + : " Top-level keys (\(topLevelKeys.count)): \(shownKeys.joined(separator: ", "))\(remainder > 0 ? " (+\(remainder) more)" : "")") + if topLevelKeyResult.truncated { + lines.append(" Top-level key collection truncated") + } + + var rendererCounts: [String: Int] = [:] + var visitedNodes = 0 + var truncated = false + for chunk in decoded.chunks { + guard visitedNodes < 100_000 else { + truncated = true + break + } + Self.collectRenderers( + in: chunk, + depth: 0, + counts: &rendererCounts, + visitedNodes: &visitedNodes, + truncated: &truncated + ) + } + if rendererCounts.isEmpty { + lines.append(" Renderer/view-model keys: none detected") + if truncated { + lines.append(" Renderer traversal truncated after \(visitedNodes) nodes") + } + } else { + lines.append(" Renderer/view-model keys:") + let sorted = rendererCounts.sorted { + $0.value != $1.value ? $0.value > $1.value : $0.key < $1.key + } + for (name, count) in sorted.prefix(Self.maximumRendererTypes) { + lines.append(" - \(name): \(count)") + } + if sorted.count > Self.maximumRendererTypes { + lines.append(" - +\(sorted.count - Self.maximumRendererTypes) more type(s)") + } + if truncated { + lines.append(" - traversal bounded after \(visitedNodes) nodes") + } + } + + var askAuditor = AskVideoResponseAuditor() + let root = decoded.chunks.count == 1 + ? (decoded.chunks.first as? [String: Any] ?? ["wireChunks": decoded.chunks]) + : ["wireChunks": decoded.chunks] + let askReport = askAuditor.audit(root) + if askReport.hasAIEvidence { + lines.append(askReport.rendered()) + } + return lines.joined(separator: "\n") + } + + private static func prepare(_ data: Data) -> (data: Data, hadXSSI: Bool) { + var bytes = [UInt8](data) + if bytes.starts(with: [0xEF, 0xBB, 0xBF]) { + bytes.removeFirst(3) + } + var start = 0 + AskVideoAuditSafety.skipASCIIWhitespace(in: bytes, index: &start) + let prefixes = [Array(")]}'".utf8), Array("for(;;);".utf8), Array("while(1);".utf8)] + let prefix = prefixes.first { candidate in + candidate.count <= bytes.count - start + && bytes[start ..< start + candidate.count].elementsEqual(candidate) + } + if let prefix { + start += prefix.count + if start < bytes.count, bytes[start] == 44 { + start += 1 + } + AskVideoAuditSafety.skipASCIIWhitespace(in: bytes, index: &start) + } + return (start < bytes.count ? Data(bytes[start...]) : Data(), prefix != nil) + } + + private static func decode(data: Data, hadXSSI: Bool) -> (classification: String, chunks: [Any]) { + let trimmed = Data(AskVideoAuditSafety.trimASCIIWhitespace([UInt8](data))) + let prefix = hadXSSI ? "XSSI-prefixed " : "" + guard !trimmed.isEmpty else { return (prefix + "empty response", []) } + if let json = Self.parseJSON(trimmed) { + if let array = json as? [Any] { + let chunks = Array(array.prefix(Self.maximumFrames)) + let suffix = array.count > Self.maximumFrames + ? " (truncated to \(Self.maximumFrames) frames)" + : "" + return (prefix + "JSON array" + suffix, chunks) + } + return (prefix + "JSON " + Self.rootType(json), [json]) + } + if let parsed = Self.parseLengthPrefixedJSON(trimmed) { + let suffix = parsed.limitDescription.map { " (\($0))" } + ?? (parsed.truncated ? " (truncated at \(Self.maximumFrames) frames)" : "") + return (prefix + "length-prefixed JSON stream" + suffix, parsed.chunks) + } + if let parsed = Self.parseNewlineDelimitedJSON(trimmed) { + let suffix = parsed.limitDescription.map { " (\($0))" } + ?? (parsed.truncated ? " (truncated at \(Self.maximumFrames) frames)" : "") + return (prefix + "newline-delimited JSON stream" + suffix, parsed.chunks) + } + return (prefix + (Self.isLikelyText(trimmed) ? "opaque text response" : "opaque/binary response"), []) + } + + private static func parseJSON(_ data: Data) -> Any? { + try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) + } + + private static func parseLengthPrefixedJSON(_ data: Data) -> ParsedChunks? { + let bytes = [UInt8](data) + var index = 0 + var chunks: [Any] = [] + while true { + AskVideoAuditSafety.skipASCIIWhitespace(in: bytes, index: &index) + if index >= bytes.count { + break + } + if chunks.count >= Self.maximumFrames { + return ParsedChunks(chunks: chunks, truncated: true, limitDescription: nil) + } + let lineStart = index + while index < bytes.count, bytes[index] != 10, bytes[index] != 13 { + index += 1 + } + guard index < bytes.count else { return nil } + let lengthBytes = AskVideoAuditSafety.trimASCIIWhitespace(Array(bytes[lineStart ..< index])) + guard !lengthBytes.isEmpty, + lengthBytes.allSatisfy({ (48 ... 57).contains($0) }) + else { + return nil + } + var length = 0 + for byte in lengthBytes { + let digit = Int(byte - 48) + if length > (Self.maximumFrameBytes - digit) / 10 { + return ParsedChunks( + chunks: chunks, + truncated: true, + limitDescription: "frame exceeds 4 MiB audit limit" + ) + } + length = length * 10 + digit + } + guard length > 0 else { return nil } + if length > Self.maximumFrameBytes { + return ParsedChunks( + chunks: chunks, + truncated: true, + limitDescription: "frame exceeds 4 MiB audit limit" + ) + } + if bytes[index] == 13 { + index += 1 + if index < bytes.count, bytes[index] == 10 { + index += 1 + } + } else { + index += 1 + } + guard length <= bytes.count - index, + let chunk = Self.parseJSON(Data(bytes[index ..< index + length])) + else { + return nil + } + chunks.append(chunk) + index += length + } + return chunks.isEmpty + ? nil + : ParsedChunks(chunks: chunks, truncated: false, limitDescription: nil) + } + + private static func parseNewlineDelimitedJSON(_ data: Data) -> ParsedChunks? { + let bytes = [UInt8](data) + var chunks: [Any] = [] + var lineStart = 0 + var index = 0 + while index <= bytes.count { + if chunks.count >= Self.maximumFrames { + let remaining = bytes[index...] + let hasMoreData = remaining.contains { !AskVideoAuditSafety.isASCIIWhitespace($0) } + return ParsedChunks( + chunks: chunks, + truncated: hasMoreData, + limitDescription: nil + ) + } + if index < bytes.count, bytes[index] != 10 { + guard index - lineStart <= Self.maximumFrameBytes else { + return ParsedChunks( + chunks: chunks, + truncated: true, + limitDescription: "frame exceeds 4 MiB audit limit" + ) + } + index += 1 + continue + } + + let line = AskVideoAuditSafety.trimASCIIWhitespace(Array(bytes[lineStart ..< index])) + lineStart = index + 1 + index += 1 + if line.isEmpty { + continue + } + if line.count > Self.maximumFrameBytes { + return ParsedChunks( + chunks: chunks, + truncated: true, + limitDescription: "frame exceeds 4 MiB audit limit" + ) + } + guard !line.allSatisfy({ (48 ... 57).contains($0) }), + let chunk = Self.parseJSON(Data(line)) + else { + return nil + } + chunks.append(chunk) + } + return chunks.count >= 2 + ? ParsedChunks(chunks: chunks, truncated: false, limitDescription: nil) + : nil + } + + private static func collectRenderers( + in value: Any, + depth: Int, + counts: inout [String: Int], + visitedNodes: inout Int, + truncated: inout Bool + ) { + guard depth <= 80, visitedNodes < 100_000 else { + truncated = true + return + } + visitedNodes += 1 + if let dictionary = value as? [String: Any] { + if dictionary.count > self.maximumChildrenPerContainer { + truncated = true + } + for (key, nestedValue) in dictionary.prefix(self.maximumChildrenPerContainer) { + guard visitedNodes < 100_000 else { + truncated = true + break + } + if key.hasSuffix("Renderer") || key.hasSuffix("ViewModel") { + let safeKey = AskVideoAuditSafety.safeResponseKey(key) ?? "" + if counts[safeKey] != nil || counts.count < self.maximumRendererKinds { + counts[safeKey, default: 0] += 1 + } else { + truncated = true + } + } + self.collectRenderers( + in: nestedValue, + depth: depth + 1, + counts: &counts, + visitedNodes: &visitedNodes, + truncated: &truncated + ) + } + } else if let array = value as? [Any] { + if array.count > Self.maximumChildrenPerContainer { + truncated = true + } + for nestedValue in array.prefix(Self.maximumChildrenPerContainer) { + guard visitedNodes < 100_000 else { + truncated = true + break + } + Self.collectRenderers( + in: nestedValue, + depth: depth + 1, + counts: &counts, + visitedNodes: &visitedNodes, + truncated: &truncated + ) + } + } + } + + private static func topLevelKeys(in chunks: [Any]) -> (keys: [String], truncated: Bool) { + var keys: Set = [] + var truncated = false + func collect(_ dictionary: [String: Any]) { + if dictionary.count > 128 { + truncated = true + } + for key in dictionary.keys.prefix(128) where keys.count < 128 { + keys.insert(AskVideoAuditSafety.safeResponseKey(key) ?? "") + } + if keys.count >= 128, dictionary.count > 128 { + truncated = true + } + } + for chunk in chunks.prefix(Self.maximumFrames) { + if keys.count >= 128 { + truncated = true + break + } + if let dictionary = chunk as? [String: Any] { + collect(dictionary) + } else if let array = chunk as? [Any] { + if array.count > Self.maximumChildrenPerContainer { + truncated = true + } + for case let dictionary as [String: Any] in array.prefix(Self.maximumChildrenPerContainer) { + guard keys.count < 128 else { break } + collect(dictionary) + } + } + } + return (keys.sorted(), truncated) + } + + private static func rootType(_ value: Any) -> String { + value is [String: Any] ? "object" : (value is [Any] ? "array" : "scalar") + } + + private static func safeMediaType(_ contentType: String?) -> String { + guard let contentType else { return "unknown" } + let mediaType = contentType + .split(separator: ";", maxSplits: 1) + .first + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } + let recognized: Set = [ + "application/ecmascript", "application/javascript", "application/json", + "application/octet-stream", "application/problem+json", "application/xhtml+xml", + "text/ecmascript", "text/html", "text/javascript", "text/plain", + ] + guard let mediaType else { return "unknown" } + return recognized.contains(mediaType) ? mediaType : "other" + } + + private static func isLikelyText(_ data: Data) -> Bool { + guard String(data: data, encoding: .utf8) != nil else { return false } + let bytes = [UInt8](data) + guard !bytes.contains(0) else { return false } + let controls = bytes.filter { $0 < 32 && !AskVideoAuditSafety.isASCIIWhitespace($0) }.count + return bytes.isEmpty || controls * 10 <= bytes.count + } +} + +// MARK: - YouTubeMainAppScriptExtractor + +private enum YouTubeMainAppScriptExtractor { + private struct Candidate { + let url: URL + let score: Int + let order: Int + } + + static func extract(from html: String, baseURL: URL) -> URL? { + guard let baseHost = baseURL.host?.lowercased(), + AskVideoAuditSafety.isYouTubeHost(baseHost), + let expression = try? NSRegularExpression( + pattern: #"]*>"#, + options: [.caseInsensitive, .dotMatchesLineSeparators] + ) + else { + return nil + } + + let range = NSRange(html.startIndex ..< html.endIndex, in: html) + var candidates: [Candidate] = [] + for (order, match) in expression.matches(in: html, range: range).enumerated() { + guard let tagRange = Range(match.range, in: html) else { continue } + let tag = String(html[tagRange]) + let attributes = Self.attributes(in: tag) + guard let source = attributes["src"], + let url = Self.safeScriptURL(source, baseURL: baseURL) + else { + continue + } + let lowerTag = tag.lowercased() + let lowerSource = source.lowercased() + let identifiers = ["id", "name", "data-id", "data-name"] + .compactMap { attributes[$0]?.lowercased() } + let score = if lowerTag.contains("ytmainappweb") { + 100 + } else if identifiers.contains("base-js"), + lowerSource.contains("/s/desktop/"), lowerSource.contains("/jsbin/") + { + 90 + } else if lowerSource.contains("/jsbin/ytmainappweb") + || lowerSource.contains("/jsbin/www-main-app") + || lowerSource.contains("/jsbin/desktop_polymer") + { + 80 + } else { + 0 + } + if score > 0 { + candidates.append(Candidate(url: url, score: score, order: order)) + } + } + return candidates.max { lhs, rhs in + lhs.score == rhs.score ? lhs.order > rhs.order : lhs.score < rhs.score + }?.url + } + + private static func attributes(in tag: String) -> [String: String] { + guard let expression = try? NSRegularExpression( + pattern: #"([A-Za-z_:][A-Za-z0-9_.:-]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))"#, + options: .caseInsensitive + ) else { + return [:] + } + let range = NSRange(tag.startIndex ..< tag.endIndex, in: tag) + var result: [String: String] = [:] + for match in expression.matches(in: tag, range: range) { + guard let nameRange = Range(match.range(at: 1), in: tag) else { continue } + for capture in 2 ... 4 where match.range(at: capture).location != NSNotFound { + if let valueRange = Range(match.range(at: capture), in: tag) { + result[tag[nameRange].lowercased()] = Self.decodeHTMLEntities(String(tag[valueRange])) + break + } + } + } + return result + } + + private static func safeScriptURL(_ source: String, baseURL: URL) -> URL? { + let decoded = Self.decodeHTMLEntities(source).trimmingCharacters(in: .whitespacesAndNewlines) + guard let url = URL(string: decoded, relativeTo: baseURL)?.absoluteURL, + url.scheme?.lowercased() == baseURL.scheme?.lowercased(), + url.host?.lowercased() == baseURL.host?.lowercased(), + Self.effectivePort(url) == Self.effectivePort(baseURL), + url.user == nil, url.password == nil + else { + return nil + } + return url + } + + private static func effectivePort(_ url: URL) -> Int? { + if let port = url.port { + return port + } + switch url.scheme?.lowercased() { + case "https": return 443 + case "http": return 80 + default: return nil + } + } + + private static func decodeHTMLEntities(_ value: String) -> String { + value.replacingOccurrences(of: "&", with: "&", options: .caseInsensitive) + .replacingOccurrences(of: """, with: "\"", options: .caseInsensitive) + .replacingOccurrences(of: "'", with: "'", options: .caseInsensitive) + .replacingOccurrences(of: "'", with: "'", options: .caseInsensitive) + } +} + +// MARK: - YouTubeAIFrontendCapabilityAuditor + +private enum YouTubeAIFrontendCapabilityAuditor { + private struct Capability { + let label: String + let needles: [String] + } + + private static let capabilities = [ + Capability(label: "/youtubei/v1/get_answer", needles: ["/youtubei/v1/get_answer", "\"get_answer\""]), + Capability(label: "/youtubei/v1/get_panel", needles: ["/youtubei/v1/get_panel", "\"get_panel\""]), + Capability(label: "/youtubei/v1/streaming_panel", needles: ["/youtubei/v1/streaming_panel", "\"streaming_panel\""]), + Capability(label: "/youtubei/v1/get_watch", needles: ["/youtubei/v1/get_watch", "\"get_watch\""]), + Capability(label: "PAyouchat", needles: ["PAyouchat"]), + Capability(label: "engagement-panel-youchat", needles: ["engagement-panel-youchat"]), + Capability(label: "PAai_companion", needles: ["PAai_companion"]), + Capability(label: "inputComposerFormData", needles: ["inputComposerFormData"]), + Capability(label: "sendUserQueryCommand", needles: ["sendUserQueryCommand"]), + Capability(label: "CONTINUATION_REQUEST_TYPE_GET_PANEL", needles: ["CONTINUATION_REQUEST_TYPE_GET_PANEL"]), + Capability(label: "youchatPendingResponseEntity", needles: ["youchatPendingResponseEntity"]), + ] + + static func summary(html: String, mainJavaScript: String?) -> String { + var detected = 0 + var lines = [ + "YouTube AI frontend capability audit", + " HTML characters scanned: \(html.count)", + " Main JavaScript: \(mainJavaScript == nil ? "not provided" : "provided")", + ] + for capability in Self.capabilities { + let htmlCount = capability.needles.reduce(0) { $0 + Self.count($1, in: html) } + let scriptCount = mainJavaScript.map { script in + capability.needles.reduce(0) { $0 + Self.count($1, in: script) } + } ?? 0 + if htmlCount > 0 || scriptCount > 0 { + detected += 1 + } + let htmlStatus = htmlCount > 0 ? "present (\(htmlCount))" : "absent" + let scriptStatus = mainJavaScript == nil + ? "not scanned" + : (scriptCount > 0 ? "present (\(scriptCount))" : "absent") + lines.append(" - \(capability.label): HTML=\(htmlStatus), mainJS=\(scriptStatus)") + } + lines += [ + " Detected capabilities: \(detected)/\(Self.capabilities.count)", + " Source contents: not displayed", + ] + return lines.joined(separator: "\n") + } + + private static func count(_ needle: String, in haystack: String) -> Int { + var count = 0 + var start = haystack.startIndex + while start < haystack.endIndex, + let range = haystack.range(of: needle, range: start ..< haystack.endIndex) + { + count += 1 + start = range.upperBound + } + return count + } +} + +// MARK: - YouTubeAIFrontendFlowDebugAuditor + +private enum YouTubeAIFrontendFlowDebugAuditor { + private static let markers = [ + "chipData", + "lastMessageIdEntityKey", + "sendUserQueryCommand", + "inputComposerFormData", + "pendingSuggestedQueryIdentifier", + "clientMessageId", + "userInputText", + ] + + private static let preservedIdentifiers: Set = [ + "CONTINUATION_REQUEST_TYPE_GET_PANEL", + "clientMessageId", + "chipData", + "clickTrackingParams", + "content", + "continuation", + "continuationCommand", + "conversationId", + "currentUtcTimeMillis", + "formData", + "formDataDecoratorCommand", + "get_panel", + "id", + "innertubeCommand", + "inputComposerFormData", + "lastMessageIdEntityKey", + "listMutationCommand", + "onClick", + "pageContext", + "pendingStateEntityKey", + "pendingSuggestedQueryIdentifier", + "playerOffsetMs", + "previousClientMessageId", + "request", + "sendUserQueryCommand", + "streaming_panel", + "text", + "token", + "transparentWhenLoading", + "userInputText", + ] + + static func summary(mainJavaScript: String) -> String { + var lines = [ + "YouTube AI frontend flow contexts", + " Source is normalized; non-schema identifiers and string values are removed", + ] + for marker in Self.markers { + let contexts = Self.contexts(around: marker, in: mainJavaScript) + lines.append(" - \(marker): \(contexts.count) normalized context(s)") + for context in contexts { + lines.append(" \(context)") + } + } + return lines.joined(separator: "\n") + } + + private static func contexts(around marker: String, in source: String) -> [String] { + var contexts: [String] = [] + var searchStart = source.startIndex + while contexts.count < 4, + searchStart < source.endIndex, + let range = source.range(of: marker, range: searchStart ..< source.endIndex) + { + let lower = source.index(range.lowerBound, offsetBy: -520, limitedBy: source.startIndex) + ?? source.startIndex + let upper = source.index(range.upperBound, offsetBy: 760, limitedBy: source.endIndex) + ?? source.endIndex + let normalized = Self.normalize(String(source[lower ..< upper])) + if !contexts.contains(normalized) { + contexts.append(normalized) + } + searchStart = range.upperBound + } + return contexts + } + + private static func normalize(_ source: String) -> String { + let scalars = Array(source.unicodeScalars) + var output = "" + var index = 0 + var emittedWhitespace = false + + func append(_ value: String) { + output.append(value) + emittedWhitespace = false + } + + while index < scalars.count { + let scalar = scalars[index] + if CharacterSet.whitespacesAndNewlines.contains(scalar) { + if !emittedWhitespace { + output.append(" ") + emittedWhitespace = true + } + index += 1 + continue + } + if scalar == "\"" || scalar == "'" { + let quote = scalar + var value = "" + index += 1 + var escaped = false + while index < scalars.count { + let current = scalars[index] + index += 1 + if escaped { + escaped = false + continue + } + if current == "\\" { + escaped = true + continue + } + if current == quote { + break + } + value.unicodeScalars.append(current) + } + append(Self.preservedIdentifiers.contains(value) ? "\"\(value)\"" : "\"\"") + continue + } + if Self.isIdentifierStart(scalar) { + var identifier = "" + identifier.unicodeScalars.append(scalar) + index += 1 + while index < scalars.count, Self.isIdentifierContinue(scalars[index]) { + identifier.unicodeScalars.append(scalars[index]) + index += 1 + } + append(Self.preservedIdentifiers.contains(identifier) ? identifier : "v") + continue + } + if CharacterSet.decimalDigits.contains(scalar) { + while index < scalars.count, + CharacterSet.decimalDigits.contains(scalars[index]) + { + index += 1 + } + append("n") + continue + } + append(String(scalar)) + index += 1 + } + + return output.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func isIdentifierStart(_ scalar: Unicode.Scalar) -> Bool { + scalar == "$" || scalar == "_" || CharacterSet.letters.contains(scalar) + } + + private static func isIdentifierContinue(_ scalar: Unicode.Scalar) -> Bool { + self.isIdentifierStart(scalar) || CharacterSet.decimalDigits.contains(scalar) + } +} diff --git a/Sources/APIExplorer/main.swift b/Sources/APIExplorer/main.swift index 126c32ffc..294ea6360 100755 --- a/Sources/APIExplorer/main.swift +++ b/Sources/APIExplorer/main.swift @@ -11,7 +11,11 @@ // // Commands: // browse [params] - Explore a browse endpoint -// action - Explore an action endpoint (body as JSON) +// action - Explore a JSON action endpoint (body as JSON) +// wire-action - Safely inspect JSON, streaming, or opaque responses +// ask-video-audit - Audit YouTube Ask Gemini / YouChat API surfaces +// ask-video-parity - Compare read-only Ask request profiles +// ask-video-live-test - Replay server-issued summary/follow-up suggestions // search-audit - Audit live YouTube Music search shapes and filters // continuation [ep] - Explore a continuation (ep: browse, search, or next) // analyze-file - Safely summarize a saved JSON response @@ -23,6 +27,9 @@ // -v, --verbose - Show raw JSON, or expanded search-audit samples // -o, --output - Save raw JSON response to a file // --client-version - Override the resolved InnerTube client version +// --confirm-live-ai - Required acknowledgement for live AI requests +// --fresh-chats N - Run 1-3 independent summary chats +// --follow-up - Replay one server-issued follow-up suggestion // --youtube, --yt - Target regular YouTube (www.youtube.com, WEB client) // instead of YouTube Music // --no-auth, --guest - Force unauthenticated requests even if Kaset cookies exist @@ -32,12 +39,15 @@ // swift run api-explorer browse FEmusic_charts // swift run api-explorer browse FEmusic_liked_playlists # Requires auth // swift run api-explorer action search '{"query":"never gonna give you up"}' +// swift run api-explorer ask-video-parity +// swift run api-explorer ask-video-live-test --confirm-live-ai --follow-up // swift run api-explorer continuation next # Mix queue continuation // swift run api-explorer auth // swift run api-explorer list // import CommonCrypto +import Darwin import Dispatch import Foundation @@ -79,10 +89,216 @@ func activateYouTubeMode() { /// Global auth user index (0 = primary account, 1+ = brand accounts) nonisolated(unsafe) var globalAuthUserIndex = 0 +nonisolated(unsafe) var authUserOptionWasSpecified = false /// Global brand account ID (21-digit number from myaccount.google.com/brandaccounts) nonisolated(unsafe) var globalBrandAccountId: String? +private func effectivePort(for url: URL) -> Int? { + if let port = url.port { + return port + } + switch url.scheme?.lowercased() { + case "https": return 443 + case "http": return 80 + default: return nil + } +} + +// MARK: - BoundedResponseDataDelegate + +private final class BoundedResponseDataDelegate: NSObject, URLSessionDataDelegate, @unchecked Sendable { + private let scheme: String? + private let host: String? + private let port: Int? + private let maximumBytes: Int + private let lock = NSLock() + + private var continuation: CheckedContinuation<(Data, URLResponse), any Error>? + private var session: URLSession? + private var task: URLSessionDataTask? + private var response: URLResponse? + private var responseData = Data() + private var isFinished = false + private var cancellationRequested = false + + init(originURL: URL, maximumBytes: Int) { + self.scheme = originURL.scheme?.lowercased() + self.host = originURL.host?.lowercased() + self.port = effectivePort(for: originURL) + self.maximumBytes = maximumBytes + } + + func load( + configuration: URLSessionConfiguration, + request: URLRequest + ) async throws -> (Data, URLResponse) { + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + let session = URLSession( + configuration: configuration, + delegate: self, + delegateQueue: nil + ) + let task = session.dataTask(with: request) + + self.lock.lock() + if self.cancellationRequested { + self.isFinished = true + self.lock.unlock() + session.invalidateAndCancel() + continuation.resume(throwing: CancellationError()) + return + } + self.continuation = continuation + self.session = session + self.task = task + self.lock.unlock() + + task.resume() + } + } onCancel: { + self.cancel() + } + } + + func urlSession( + _: URLSession, + task _: URLSessionTask, + willPerformHTTPRedirection _: HTTPURLResponse, + newRequest request: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void + ) { + guard let url = request.url, + url.scheme?.lowercased() == self.scheme, + url.host?.lowercased() == self.host, + effectivePort(for: url) == self.port + else { + completionHandler(nil) + return + } + completionHandler(request) + } + + func urlSession( + _: URLSession, + dataTask _: URLSessionDataTask, + didReceive response: URLResponse, + completionHandler: @escaping (URLSession.ResponseDisposition) -> Void + ) { + let exceedsLimit = response.expectedContentLength > 0 + && response.expectedContentLength > Int64(self.maximumBytes) + guard !exceedsLimit else { + completionHandler(.cancel) + self.finish( + .failure(ResponseSizeLimitError(maximumBytes: self.maximumBytes)), + cancelSession: true + ) + return + } + + self.lock.lock() + if !self.isFinished { + self.response = response + if response.expectedContentLength > 0 { + self.responseData.reserveCapacity( + min(Int(response.expectedContentLength), self.maximumBytes) + ) + } + } + self.lock.unlock() + completionHandler(.allow) + } + + func urlSession( + _: URLSession, + dataTask _: URLSessionDataTask, + didReceive data: Data + ) { + var exceedsLimit = false + self.lock.lock() + if !self.isFinished { + if data.count > self.maximumBytes - self.responseData.count { + exceedsLimit = true + } else { + self.responseData.append(data) + } + } + self.lock.unlock() + + if exceedsLimit { + self.finish( + .failure(ResponseSizeLimitError(maximumBytes: self.maximumBytes)), + cancelSession: true + ) + } + } + + func urlSession( + _: URLSession, + task _: URLSessionTask, + didCompleteWithError error: (any Error)? + ) { + if let error { + self.finish(.failure(error), cancelSession: true) + return + } + + self.lock.lock() + let response = self.response + let data = self.responseData + self.lock.unlock() + + guard let response else { + self.finish( + .failure(NSError( + domain: "APIExplorer", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Response completed without metadata"] + )), + cancelSession: true + ) + return + } + self.finish(.success((data, response)), cancelSession: false) + } + + private func cancel() { + self.lock.lock() + self.cancellationRequested = true + let shouldFinish = self.continuation != nil && !self.isFinished + self.lock.unlock() + if shouldFinish { + self.finish(.failure(CancellationError()), cancelSession: true) + } + } + + private func finish( + _ result: Result<(Data, URLResponse), any Error>, + cancelSession: Bool + ) { + self.lock.lock() + guard !self.isFinished else { + self.lock.unlock() + return + } + self.isFinished = true + let continuation = self.continuation + let session = self.session + self.continuation = nil + self.session = nil + self.task = nil + self.lock.unlock() + + if cancelSession { + session?.invalidateAndCancel() + } else { + session?.finishTasksAndInvalidate() + } + continuation?.resume(with: result) + } +} + // MARK: - Cookie Management /// Reads cookies from Kaset app's backup file in Application Support. @@ -100,50 +316,69 @@ func loadCookiesFromAppBackup() -> [HTTPCookie]? { return nil } - let cookieFile = + let legacyCookieFile = appSupport .appendingPathComponent("Kaset", isDirectory: true) .appendingPathComponent("cookies.dat") - guard FileManager.default.fileExists(atPath: cookieFile.path) else { - return nil - } - - guard let data = try? Data(contentsOf: cookieFile) else { - print("⚠️ Cookie file exists but failed to read: \(cookieFile.path)") - return nil - } + let containerCookieFile = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Containers/com.sertacozercan.Kaset/Data", isDirectory: true) + .appendingPathComponent("Library/Application Support/Kaset", isDirectory: true) + .appendingPathComponent("cookies.dat") - guard let cookieDataArray = try? NSKeyedUnarchiver.unarchivedObject( - ofClasses: [NSArray.self, NSData.self], - from: data - ) as? [Data] - else { - print( - "⚠️ Cookie file exists but failed to unarchive. File may be corrupted or use a different format." - ) - print(" Path: \(cookieFile.path)") - print(" Size: \(data.count) bytes") - return nil - } + func decodeCookies(at cookieFile: URL) -> [HTTPCookie]? { + guard let data = try? Data(contentsOf: cookieFile) else { + print("⚠️ Cookie file exists but failed to read: \(cookieFile.path)") + return nil + } - let cookies = cookieDataArray.compactMap { cookieData -> HTTPCookie? in - guard let stringProperties = try? NSKeyedUnarchiver.unarchivedObject( - ofClasses: [NSDictionary.self, NSString.self, NSDate.self, NSNumber.self], - from: cookieData - ) as? [String: Any] + guard let cookieDataArray = try? NSKeyedUnarchiver.unarchivedObject( + ofClasses: [NSArray.self, NSData.self], + from: data + ) as? [Data] else { + print( + "⚠️ Cookie file exists but failed to unarchive. File may be corrupted or use a different format." + ) + print(" Path: \(cookieFile.path)") + print(" Size: \(data.count) bytes") return nil } - var convertedProperties: [HTTPCookiePropertyKey: Any] = [:] - for (key, value) in stringProperties { - convertedProperties[HTTPCookiePropertyKey(key)] = value + let cookies = cookieDataArray.compactMap { cookieData -> HTTPCookie? in + guard let stringProperties = try? NSKeyedUnarchiver.unarchivedObject( + ofClasses: [NSDictionary.self, NSString.self, NSDate.self, NSNumber.self], + from: cookieData + ) as? [String: Any] + else { + return nil + } + + var convertedProperties: [HTTPCookiePropertyKey: Any] = [:] + for (key, value) in stringProperties { + convertedProperties[HTTPCookiePropertyKey(key)] = value + } + return HTTPCookie(properties: convertedProperties) } - return HTTPCookie(properties: convertedProperties) + return cookies.isEmpty ? nil : cookies } - return cookies.isEmpty ? nil : cookies + // Once the sandboxed app has created its Application Support directory, its + // container export is authoritative. Never resurrect the legacy host archive + // after logout, account switching, expiry, corruption, or a cleared export. + if FileManager.default.fileExists(atPath: containerCookieFile.path) { + return decodeCookies(at: containerCookieFile) + } + + let containerStorageDirectory = containerCookieFile.deletingLastPathComponent() + if FileManager.default.fileExists(atPath: containerStorageDirectory.path) { + return nil + } + + guard FileManager.default.fileExists(atPath: legacyCookieFile.path) else { + return nil + } + return decodeCookies(at: legacyCookieFile) } /// Filters cookies to those that match the active API host @@ -168,9 +403,9 @@ func filterCookiesForAPIHost(_ cookies: [HTTPCookie]) -> [HTTPCookie] { func getSAPISID(from cookies: [HTTPCookie]) -> String? { // Filter to youtube.com domain cookies first (better match for the API host) let ytCookies = filterCookiesForAPIHost(cookies) - let secureCookie = ytCookies.first { $0.name == "__Secure-3PAPISID" } - let fallbackCookie = ytCookies.first { $0.name == "SAPISID" } - return (secureCookie ?? fallbackCookie)?.value + let sapisid = ytCookies.first { $0.name == "SAPISID" } + let fallbackCookie = ytCookies.first { $0.name == "__Secure-3PAPISID" } + return (sapisid ?? fallbackCookie)?.value } /// Builds a cookie header string using HTTPCookie's built-in method. @@ -200,6 +435,38 @@ func computeSAPISIDHASH(sapisid: String) -> String { return "\(timestamp)_\(hashHex)" } +func buildSIDAuthorizationHeader( + from cookies: [HTTPCookie], + includeAllAvailableProofs: Bool +) -> String? { + let ytCookies = filterCookiesForAPIHost(cookies) + let sapisid = ytCookies.first { $0.name == "SAPISID" }?.value + ?? ytCookies.first { $0.name == "__Secure-3PAPISID" }?.value + let oneParty = ytCookies.first { $0.name == "__Secure-1PAPISID" }?.value + let threeParty = ytCookies.first { $0.name == "__Secure-3PAPISID" }?.value + let timestamp = Int(Date().timeIntervalSince1970) + + func authorization(scheme: String, sid: String?) -> String? { + guard let sid else { return nil } + let input = "\(timestamp) \(sid) \(activeOrigin)" + let data = Data(input.utf8) + var hash = [UInt8](repeating: 0, count: Int(CC_SHA1_DIGEST_LENGTH)) + data.withUnsafeBytes { buffer in + _ = CC_SHA1(buffer.baseAddress, CC_LONG(buffer.count), &hash) + } + return "\(scheme) \(timestamp)_\(hash.map { String(format: "%02x", $0) }.joined())" + } + + var values = [authorization(scheme: "SAPISIDHASH", sid: sapisid)].compactMap(\.self) + if includeAllAvailableProofs { + values.append(contentsOf: [ + authorization(scheme: "SAPISID1PHASH", sid: oneParty), + authorization(scheme: "SAPISID3PHASH", sid: threeParty), + ].compactMap(\.self)) + } + return values.isEmpty ? nil : values.joined(separator: " ") +} + // MARK: - API Key Resolution private func webClientConfigurationRequest(timeout: TimeInterval? = nil) -> URLRequest { @@ -214,7 +481,21 @@ private func webClientConfigurationRequest(timeout: TimeInterval? = nil) -> URLR return request } -func resolveAPIKey() async throws -> String { +private func webClientConfiguration(authenticated: Bool) -> URLSessionConfiguration { + let configuration = URLSessionConfiguration.ephemeral + if authenticated, let cookies = loadCookiesFromAppBackup(), !cookies.isEmpty { + let storage = HTTPCookieStorage() + for cookie in cookies { + storage.setCookie(cookie) + } + configuration.httpCookieStorage = storage + configuration.httpShouldSetCookies = true + configuration.httpCookieAcceptPolicy = .always + } + return configuration +} + +func resolveAPIKey(authenticated: Bool = false) async throws -> String { if let cachedAPIKey { return cachedAPIKey } @@ -224,11 +505,16 @@ func resolveAPIKey() async throws -> String { { let trimmed = override.trimmingCharacters(in: .whitespacesAndNewlines) cachedAPIKey = trimmed + await resolveLiveClientVersionIfNeeded(authenticated: authenticated) return trimmed } let request = webClientConfigurationRequest() - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await boundedResponseData( + configuration: webClientConfiguration(authenticated: authenticated), + request: request, + maximumBytes: maximumConfigurationResponseBytes + ) if let httpResponse = response as? HTTPURLResponse, !(200 ... 399).contains(httpResponse.statusCode) { @@ -256,7 +542,6 @@ func resolveAPIKey() async throws -> String { { cachedClientVersion = version } - cachedAPIKey = key return key } @@ -264,21 +549,35 @@ func resolveAPIKey() async throws -> String { /// Resolves only the live client version when the API key came from an explicit /// environment override. Failure is non-fatal: callers can still use the /// configured fallback, and search-audit labels that source explicitly. -func resolveLiveClientVersionIfNeeded() async { +func resolveLiveClientVersionIfNeeded(authenticated: Bool = false) async { guard cachedClientVersion == nil else { return } let request = webClientConfigurationRequest(timeout: 5) + let data: Data + let response: URLResponse + do { + (data, response) = try await boundedResponseData( + configuration: webClientConfiguration(authenticated: authenticated), + request: request, + maximumBytes: maximumConfigurationResponseBytes + ) + } catch let error as ResponseSizeLimitError { + print("⚠️ Web client configuration skipped: \(error.localizedDescription)") + return + } catch { + return + } - guard let (data, response) = try? await URLSession.shared.data(for: request), - let httpResponse = response as? HTTPURLResponse, + guard let httpResponse = response as? HTTPURLResponse, (200 ... 399).contains(httpResponse.statusCode), - let html = String(data: data, encoding: .utf8), - let version = extractInnertubeClientVersion(from: html) + let html = String(data: data, encoding: .utf8) else { return } - cachedClientVersion = version + if let version = extractInnertubeClientVersion(from: html) { + cachedClientVersion = version + } } func extractInnertubeAPIKey(from html: String) -> String? { @@ -304,6 +603,34 @@ func extractConfigValue(named name: String, from html: String) -> String? { return String(html[range]) } +func extractConfigBoolean(named name: String, from html: String) -> Bool? { + let pattern = "\"\(name)\"\\s*:\\s*(true|false)" + guard let regex = try? NSRegularExpression(pattern: pattern), + let match = regex.firstMatch( + in: html, + range: NSRange(html.startIndex..., in: html) + ), + let range = Range(match.range(at: 1), in: html) + else { + return nil + } + return html[range] == "true" +} + +func extractConfigInteger(named name: String, from html: String) -> Int? { + let pattern = "\"\(name)\"\\s*:\\s*(-?[0-9]+)" + guard let regex = try? NSRegularExpression(pattern: pattern), + let match = regex.firstMatch( + in: html, + range: NSRange(html.startIndex..., in: html) + ), + let range = Range(match.range(at: 1), in: html) + else { + return nil + } + return Int(html[range]) +} + // MARK: - Request Builder func buildContext(brandAccountId: String? = nil) -> [String: Any] { @@ -346,14 +673,15 @@ func buildHeaders(authenticated: Bool = false, authUserIndex: Int? = nil) -> [St "Origin": activeOrigin, "Referer": "\(activeOrigin)/", ] - if authenticated, let cookies = loadCookiesFromAppBackup() { - if let sapisid = getSAPISID(from: cookies), - let cookieHeader = buildCookieHeader(from: cookies) + if let authorization = buildSIDAuthorizationHeader( + from: cookies, + includeAllAvailableProofs: false + ), + let cookieHeader = buildCookieHeader(from: cookies) { - let sapisidhash = computeSAPISIDHASH(sapisid: sapisid) headers["Cookie"] = cookieHeader - headers["Authorization"] = "SAPISIDHASH \(sapisidhash)" + headers["Authorization"] = authorization headers["X-Goog-AuthUser"] = "\(authUserIndex ?? globalAuthUserIndex)" headers["X-Origin"] = activeOrigin // Brand/delegated channel selection on the wire: real-world clients @@ -375,12 +703,75 @@ func hasUsableAuthMaterial() -> Bool { return getSAPISID(from: cookies) != nil && buildCookieHeader(from: cookies) != nil } -// MARK: - API Request +// MARK: - APIWireResponse -func makeRequest(endpoint: String, body: [String: Any], authenticated: Bool = false) async throws - -> (data: [String: Any], statusCode: Int) +struct APIWireResponse { + let data: Data + let statusCode: Int + let contentType: String? +} + +private let maximumWireResponseBytes = 32 * 1024 * 1024 +private let maximumConfigurationResponseBytes = 8 * 1024 * 1024 + +// MARK: - ResponseSizeLimitError + +struct ResponseSizeLimitError: LocalizedError { + let maximumBytes: Int + + var errorDescription: String? { + "Response exceeds the configured \(self.maximumBytes / 1_048_576) MiB limit" + } +} + +func boundedResponseData( + configuration: URLSessionConfiguration, + request: URLRequest, + maximumBytes: Int +) async throws -> (Data, URLResponse) { + guard let originURL = request.url else { + throw NSError( + domain: "APIExplorer", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Request URL is missing"] + ) + } + let delegate = BoundedResponseDataDelegate( + originURL: originURL, + maximumBytes: maximumBytes + ) + return try await delegate.load(configuration: configuration, request: request) +} + +func canonicalAPIEndpoint(_ endpoint: String) throws -> String { + let pathSegments = endpoint.split(separator: "/", omittingEmptySubsequences: false) + guard !endpoint.isEmpty, endpoint.count <= 160, + !endpoint.hasPrefix("/"), !endpoint.hasSuffix("/"), + !endpoint.contains("//"), + pathSegments.allSatisfy({ !$0.isEmpty && $0 != "." && $0 != ".." }), + endpoint.unicodeScalars.allSatisfy({ scalar in + switch scalar.value { + case 45, 47, 48 ... 57, 65 ... 90, 95, 97 ... 122: + true + default: + false + } + }) + else { + throw NSError( + domain: "APIExplorer", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Endpoint must be a plain relative API path"] + ) + } + return endpoint +} + +func makeWireRequest(endpoint: String, body: [String: Any], authenticated: Bool = false) async throws + -> APIWireResponse { - let apiKey = try await resolveAPIKey() + let endpoint = try canonicalAPIEndpoint(endpoint) + let apiKey = try await resolveAPIKey(authenticated: authenticated) var components = URLComponents(string: "\(activeBaseURL)/\(endpoint)") components?.queryItems = [ URLQueryItem(name: "key", value: apiKey), @@ -403,7 +794,11 @@ func makeRequest(endpoint: String, body: [String: Any], authenticated: Bool = fa fullBody["context"] = buildContext() request.httpBody = try JSONSerialization.data(withJSONObject: fullBody) - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await boundedResponseData( + configuration: .ephemeral, + request: request, + maximumBytes: maximumWireResponseBytes + ) guard let httpResponse = response as? HTTPURLResponse else { throw NSError( @@ -412,14 +807,31 @@ func makeRequest(endpoint: String, body: [String: Any], authenticated: Bool = fa ) } - guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return APIWireResponse( + data: data, + statusCode: httpResponse.statusCode, + contentType: httpResponse.value(forHTTPHeaderField: "Content-Type") + ) +} + +func makeRequest(endpoint: String, body: [String: Any], authenticated: Bool = false) async throws + -> (data: [String: Any], statusCode: Int) +{ + let response = try await makeWireRequest( + endpoint: endpoint, body: body, authenticated: authenticated + ) + + guard let json = try JSONSerialization.jsonObject(with: response.data) as? [String: Any] else { throw NSError( domain: "APIExplorer", code: -1, - userInfo: [NSLocalizedDescriptionKey: "Invalid JSON response"] + userInfo: [ + NSLocalizedDescriptionKey: + "Non-object JSON or streaming response; use wire-action for a safe structural audit", + ] ) } - return (json, httpResponse.statusCode) + return (json, response.statusCode) } // MARK: - Response Analysis @@ -1256,9 +1668,8 @@ func exploreBrowse( if let prettyData = try? JSONSerialization.data( withJSONObject: data, options: .prettyPrinted ) { - let url = URL(fileURLWithPath: outputFile) - try prettyData.write(to: url) - print("\n💾 Saved to: \(outputFile)") + try writePrivateOutput(prettyData, to: outputFile) + print("\n💾 Saved with owner-only permissions: \(outputFile)") } } } catch { @@ -1287,13 +1698,18 @@ let authRequiredActions = Set([ "next", ]) +func actionNeedsAuthentication(_ endpoint: String) -> Bool { + guard !forceUnauthenticatedRequests else { return false } + // In YouTube mode, personalized actions (guide, next, search) return richer + // data signed in, so use auth whenever cookies are available. + return authRequiredActions.contains(endpoint) + || (youtubeMode && hasUsableAuthMaterial()) +} + func exploreAction( _ endpoint: String, bodyJson: String, verbose: Bool = false, outputFile: String? = nil ) async { - // In YouTube mode, personalized actions (guide, next, search) return richer - // data signed in, so use auth whenever cookies are available. - let needsAuth = authRequiredActions.contains(endpoint) - || (youtubeMode && loadCookiesFromAppBackup() != nil) + let needsAuth = actionNeedsAuthentication(endpoint) let authIcon = needsAuth ? "🔐" : "🌐" print("\(authIcon) Exploring action endpoint: \(endpoint)") @@ -1306,7 +1722,7 @@ func exploreAction( guard let bodyData = bodyJson.data(using: .utf8), let body = try? JSONSerialization.jsonObject(with: bodyData) as? [String: Any] else { - print("❌ Invalid JSON body: \(bodyJson)") + print("❌ Invalid JSON object body") return } @@ -1344,9 +1760,8 @@ func exploreAction( if let prettyData = try? JSONSerialization.data( withJSONObject: data, options: .prettyPrinted ) { - let url = URL(fileURLWithPath: outputFile) - try prettyData.write(to: url) - print("\n💾 Saved to: \(outputFile)") + try writePrivateOutput(prettyData, to: outputFile) + print("\n💾 Saved with owner-only permissions: \(outputFile)") } } } catch { @@ -1354,6 +1769,327 @@ func exploreAction( } } +private let maximumPrivateBodyBytes = 2 * 1024 * 1024 + +private func posixError(_ description: String, code: Int32 = errno) -> NSError { + NSError( + domain: NSPOSIXErrorDomain, + code: Int(code), + userInfo: [NSLocalizedDescriptionKey: description] + ) +} + +private func writeAll(_ data: Data, fileDescriptor: Int32) throws { + try data.withUnsafeBytes { rawBuffer in + guard let baseAddress = rawBuffer.baseAddress else { return } + var offset = 0 + while offset < rawBuffer.count { + let written = Darwin.write( + fileDescriptor, + baseAddress.advanced(by: offset), + rawBuffer.count - offset + ) + if written < 0 { + if errno == EINTR { + continue + } + throw posixError("Could not write private output") + } + offset += written + } + } +} + +private func extendedACLStatus(fileDescriptor: Int32) throws -> Bool { + errno = 0 + guard let accessControlList = acl_get_fd_np(fileDescriptor, ACL_TYPE_EXTENDED) else { + if errno == ENOENT || errno == ENOTSUP || errno == EOPNOTSUPP { + return false + } + throw posixError("Could not inspect file access controls") + } + defer { _ = acl_free(UnsafeMutableRawPointer(accessControlList)) } + + var firstEntry: acl_entry_t? + errno = 0 + if acl_get_entry(accessControlList, Int32(ACL_FIRST_ENTRY.rawValue), &firstEntry) == 0 { + return true + } + if errno == EINVAL { + return false + } + throw posixError("Could not inspect file access controls") +} + +private func clearExtendedACL(fileDescriptor: Int32) throws { + guard let emptyAccessControlList = acl_init(0) else { + throw posixError("Could not initialize file access controls") + } + defer { _ = acl_free(UnsafeMutableRawPointer(emptyAccessControlList)) } + + let result = acl_set_fd_np(fileDescriptor, emptyAccessControlList, ACL_TYPE_EXTENDED) + if result == 0 || errno == ENOTSUP || errno == EOPNOTSUPP { + return + } + throw posixError("Could not clear inherited file access controls") +} + +private func verifyOwnerOnlyNode(fileDescriptor: Int32, expectedType: mode_t) throws { + var status = stat() + guard fstat(fileDescriptor, &status) == 0 else { + throw posixError("Could not verify private file") + } + guard (status.st_mode & S_IFMT) == expectedType, + status.st_uid == geteuid(), + status.st_mode & 0o077 == 0, + try !extendedACLStatus(fileDescriptor: fileDescriptor) + else { + throw NSError( + domain: "APIExplorer", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "File access is not owner-only"] + ) + } +} + +func writePrivateOutput(_ data: Data, to path: String) throws { + let url = URL(fileURLWithPath: NSString(string: path).expandingTildeInPath) + let destinationDirectory = url.deletingLastPathComponent().path + let filename = url.lastPathComponent.isEmpty ? "api-explorer-output" : url.lastPathComponent + var directoryTemplate = Array("\(destinationDirectory)/.\(filename).stage.XXXXXX".utf8CString) + guard mkdtemp(&directoryTemplate) != nil else { + throw posixError("Could not create private staging directory") + } + let stagingDirectory = String( + decoding: directoryTemplate.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) }, + as: UTF8.self + ) + guard stagingDirectory.withCString({ Darwin.chmod($0, S_IRWXU) }) == 0 else { + throw posixError("Could not secure private staging directory") + } + let stagingFile = "\(stagingDirectory)/payload" + var stagingFileExists = false + defer { + if stagingFileExists { + stagingFile.withCString { _ = Darwin.unlink($0) } + } + stagingDirectory.withCString { _ = Darwin.rmdir($0) } + } + + let directoryDescriptor = stagingDirectory.withCString { + Darwin.open($0, O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK) + } + guard directoryDescriptor >= 0 else { + throw posixError("Could not open private staging directory") + } + defer { _ = Darwin.close(directoryDescriptor) } + guard fchmod(directoryDescriptor, S_IRUSR | S_IWUSR | S_IXUSR) == 0 else { + throw posixError("Could not secure private staging directory") + } + try clearExtendedACL(fileDescriptor: directoryDescriptor) + try verifyOwnerOnlyNode(fileDescriptor: directoryDescriptor, expectedType: S_IFDIR) + + let fileDescriptor = "payload".withCString { + Darwin.openat( + directoryDescriptor, + $0, + O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, + S_IRUSR | S_IWUSR + ) + } + guard fileDescriptor >= 0 else { + throw posixError("Could not create private output") + } + stagingFileExists = true + defer { _ = Darwin.close(fileDescriptor) } + + guard fchmod(fileDescriptor, S_IRUSR | S_IWUSR) == 0 else { + throw posixError("Could not secure private output") + } + try clearExtendedACL(fileDescriptor: fileDescriptor) + try verifyOwnerOnlyNode(fileDescriptor: fileDescriptor, expectedType: S_IFREG) + try writeAll(data, fileDescriptor: fileDescriptor) + guard fsync(fileDescriptor) == 0 else { + throw posixError("Could not synchronize private output") + } + + let renameResult = stagingFile.withCString { sourcePath in + url.path.withCString { destinationPath in + Darwin.rename(sourcePath, destinationPath) + } + } + guard renameResult == 0 else { + throw posixError("Could not replace output file") + } + stagingFileExists = false +} + +private func readBoundedData(fileDescriptor: Int32) throws -> Data { + var result = Data() + var buffer = [UInt8](repeating: 0, count: 64 * 1024) + while true { + let maximumRead = min(buffer.count, maximumPrivateBodyBytes + 1 - result.count) + guard maximumRead > 0 else { + throw NSError( + domain: "APIExplorer", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Request body exceeds 2 MiB"] + ) + } + let readCount = buffer.withUnsafeMutableBytes { rawBuffer in + Darwin.read(fileDescriptor, rawBuffer.baseAddress, maximumRead) + } + if readCount == 0 { + break + } + if readCount < 0 { + if errno == EINTR { + continue + } + throw posixError("Could not read request body") + } + result.append(buffer, count: readCount) + if result.count > maximumPrivateBodyBytes { + throw NSError( + domain: "APIExplorer", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Request body exceeds 2 MiB"] + ) + } + } + return result +} + +func loadRequestBodyJSON(inlineBody: String?, bodyFile: String?) throws -> String { + if inlineBody != nil, bodyFile != nil { + throw NSError( + domain: "APIExplorer", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Use either an inline body or --body-file, not both"] + ) + } + if let inlineBody { + return inlineBody + } + guard let bodyFile else { + throw NSError( + domain: "APIExplorer", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "A JSON body or --body-file is required"] + ) + } + + let data: Data + if bodyFile == "-" { + data = try readBoundedData(fileDescriptor: STDIN_FILENO) + } else { + let expandedPath = NSString(string: bodyFile).expandingTildeInPath + let fileDescriptor = expandedPath.withCString { + Darwin.open($0, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK) + } + guard fileDescriptor >= 0 else { + throw posixError("Could not open private request body") + } + defer { _ = Darwin.close(fileDescriptor) } + + var status = stat() + guard fstat(fileDescriptor, &status) == 0 else { + throw posixError("Could not inspect private request body") + } + guard (status.st_mode & S_IFMT) == S_IFREG else { + throw NSError( + domain: "APIExplorer", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Request body path must be a regular file"] + ) + } + guard status.st_mode & 0o077 == 0 else { + throw NSError( + domain: "APIExplorer", + code: -1, + userInfo: [ + NSLocalizedDescriptionKey: + "Request body file must be owner-only (chmod 600)", + ] + ) + } + guard try !extendedACLStatus(fileDescriptor: fileDescriptor) else { + throw NSError( + domain: "APIExplorer", + code: -1, + userInfo: [ + NSLocalizedDescriptionKey: + "Request body file must not have an extended ACL (use chmod -N)", + ] + ) + } + guard status.st_size >= 0, + status.st_size <= off_t(maximumPrivateBodyBytes) + else { + throw NSError( + domain: "APIExplorer", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Request body exceeds 2 MiB"] + ) + } + data = try readBoundedData(fileDescriptor: fileDescriptor) + } + + guard !data.isEmpty, let body = String(data: data, encoding: .utf8) + else { + throw NSError( + domain: "APIExplorer", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Request body must be non-empty UTF-8 under 2 MiB"] + ) + } + return body +} + +func requiresPrivateBodySource(_ endpoint: String) -> Bool { + ["get_answer", "get_panel", "streaming_panel"].contains(endpoint) +} + +func exploreWireAction( + _ endpoint: String, bodyJson: String, outputFile: String? = nil +) async { + let needsAuth = actionNeedsAuthentication(endpoint) + let authIcon = needsAuth ? "🔐" : "🌐" + + print("\(authIcon) Inspecting wire response: \(endpoint)") + if needsAuth { + print(" Auth material: \(hasUsableAuthMaterial() ? "✅ available" : "❌ unavailable")") + } + print(" Raw response values stay hidden") + print() + + guard let bodyData = bodyJson.data(using: .utf8), + let body = try? JSONSerialization.jsonObject(with: bodyData) as? [String: Any] + else { + print("❌ Invalid JSON object body") + return + } + + do { + let response = try await makeWireRequest( + endpoint: endpoint, body: body, authenticated: needsAuth + ) + print(wireResponseAuditSummary( + data: response.data, + statusCode: response.statusCode, + contentType: response.contentType + )) + + if let outputFile { + try writePrivateOutput(response.data, to: outputFile) + print("\n💾 Saved raw response with owner-only permissions: \(outputFile)") + print(" ⚠️ Treat this file as sensitive; it may contain personalized or opaque data.") + } + } catch { + print("❌ Error: \(error.localizedDescription)") + } +} + // swiftlint:disable no_print private func auditSearchFilter( _ probe: SearchFilterProbe, @@ -1438,7 +2174,7 @@ func auditSearch(_ query: String, verbose: Bool = false) async { if ProcessInfo.processInfo.environment[apiKeyEnvironmentVariable]?.trimmingCharacters( in: .whitespacesAndNewlines ).isEmpty == false { - await resolveLiveClientVersionIfNeeded() + await resolveLiveClientVersionIfNeeded(authenticated: authenticated) } let (baseResponse, baseStatus) = try await makeRequest( endpoint: "search", @@ -1591,9 +2327,8 @@ func exploreContinuation( if let prettyData = try? JSONSerialization.data( withJSONObject: data, options: .prettyPrinted ) { - let url = URL(fileURLWithPath: outputFile) - try prettyData.write(to: url) - print("\n💾 Saved to: \(outputFile)") + try writePrivateOutput(prettyData, to: outputFile) + print("\n💾 Saved with owner-only permissions: \(outputFile)") } } } catch { @@ -1718,7 +2453,13 @@ func discoverAccounts(verbose: Bool) async { private func fetchAccountInfo(authUserIndex: Int, verbose: Bool) async -> ( name: String, handle: String? )? { - guard let apiKey = try? await resolveAPIKey() else { + let apiKey: String + do { + apiKey = try await resolveAPIKey(authenticated: true) + } catch let error as ResponseSizeLimitError { + print("⚠️ Account discovery skipped: \(error.localizedDescription)") + return nil + } catch { return nil } var components = URLComponents(string: "\(activeBaseURL)/account/account_menu") @@ -2122,7 +2863,8 @@ func probeYtcfg(pageURLString: String?, verbose: Bool) async { let dataSyncId = extractConfigValue(named: "DATASYNC_ID", from: html) let delegated = extractConfigValue(named: "DELEGATED_SESSION_ID", from: html) let sessionIndex = extractConfigValue(named: "SESSION_INDEX", from: html) - let loggedIn = extractConfigValue(named: "LOGGED_IN", from: html) + ?? extractConfigInteger(named: "SESSION_INDEX", from: html).map(String.init) + let loggedIn = extractConfigBoolean(named: "LOGGED_IN", from: html) func redact(_ value: String?) -> String { guard let value, !value.isEmpty else { return "(absent/empty)" } @@ -2147,13 +2889,16 @@ func probeYtcfg(pageURLString: String?, verbose: Bool) async { } print("DELEGATED_SESSION_ID: \(redact(delegated))") print("SESSION_INDEX: \(sessionIndex ?? "(absent)")") - print("LOGGED_IN: \(loggedIn ?? "(absent)")") + print("LOGGED_IN: \(loggedIn.map(String.init) ?? "(absent)")") if verbose { - for name in ["DATASYNC_ID", "DELEGATED_SESSION_ID", "SESSION_INDEX"] { - if extractConfigValue(named: name, from: html) == nil { - print(" (note: \(name) not found in page ytcfg)") - } + let missingFields = [ + (name: "DATASYNC_ID", isMissing: dataSyncId == nil), + (name: "DELEGATED_SESSION_ID", isMissing: delegated == nil), + (name: "SESSION_INDEX", isMissing: sessionIndex == nil), + ] + for field in missingFields where field.isMissing { + print(" (note: \(field.name) not found in page ytcfg)") } } } catch { @@ -2496,6 +3241,17 @@ func listEndpoints() { subscription/unsubscribe Body: {"channelIds": ["UC..."]} browse/edit_playlist Watch Later add/remove via playlistId "WL" + 🤖 AI / PANEL TRANSPORTS (experimental, inspect with redacted audit commands) + ─────────────────────────────────────────────────────────────────────────────── + get_answer Timed/polling AI answer command transport + get_panel Engagement-panel continuation/bootstrap transport + streaming_panel Chunked engagement-panel response transport + get_watch Combined player + watch-next bootstrap transport + + Audit a video: swift run api-explorer ask-video-audit + Compare Ask request profiles: swift run api-explorer ask-video-parity + Inspect wire format: swift run api-explorer --youtube wire-action '{}' + ═══════════════════════════════════════════════════════════════════════════════ 💡 USAGE TIPS ═══════════════════════════════════════════════════════════════════════════════ @@ -2525,7 +3281,11 @@ func showHelp() { Commands: browse [params] Explore a browse endpoint - action Explore an action endpoint (body as JSON) + action [body] Explore a JSON action endpoint + wire-action [body] Safely inspect JSON, streaming, or opaque responses + ask-video-audit Audit Ask Gemini / YouChat without sending a prompt + ask-video-parity Compare ordered read-only Ask request profiles + ask-video-live-test Replay the server-issued summary suggestion search-audit Audit live Music search shapes, filters, and continuations continuation [ep] Explore a continuation (ep: 'browse', 'search', or 'next') analyze-file Safely summarize a saved JSON response @@ -2542,8 +3302,12 @@ func showHelp() { help Show this help message Options: - -v, --verbose Show raw JSON; expand samples/params for search-audit - -o, --output Save raw JSON response to a file + -v, --verbose Show raw JSON for browse/action/continuation; expand audits + -o, --output Save raw output with owner-only permissions (mode 0600) + --body-file Read a sensitive JSON body from a chmod-600 file or stdin + --confirm-live-ai Required acknowledgement for ask-video-live-test + --fresh-chats N Run 1-3 independent summary chats (default: 1) + --follow-up Replay the first server-issued follow-up suggestion --authuser N Use Google account at index N (for multi-account) --brand Use brand account ID (21-digit number) --client-version Override the resolved InnerTube client version @@ -2562,6 +3326,11 @@ func showHelp() { swift run api-explorer --youtube --guest action search '{"query":"swift concurrency"}' swift run api-explorer --youtube action next '{"videoId":"dQw4w9WgXcQ"}' swift run api-explorer --youtube action guide '{}' # Sidebar + subscriptions list + swift run api-explorer ask-video-audit dQw4w9WgXcQ # Redacted AI audit + swift run api-explorer ask-video-parity dQw4w9WgXcQ # Read-only profile matrix + swift run api-explorer ask-video-live-test dQw4w9WgXcQ --confirm-live-ai --follow-up + swift run api-explorer --youtube wire-action get_watch '{"playerRequest":{"videoId":"dQw4w9WgXcQ"},"watchNextRequest":{"videoId":"dQw4w9WgXcQ"}}' + swift run api-explorer --youtube wire-action streaming_panel --body-file /path/to/private-body.json Examples: # Explore public endpoints @@ -2629,90 +3398,115 @@ func analyzeSavedResponse(at path: String) { // MARK: - Main Entry Point +private func commandLineOptionValue( + after index: Int, + in arguments: [String], + allowSingleDash: Bool = false +) -> String? { + guard index + 1 < arguments.count else { return nil } + let value = arguments[index + 1] + if value == "-" { + return allowSingleDash ? value : nil + } + guard !value.hasPrefix("-") else { return nil } + return value +} + func runMain() async { let args = Array(CommandLine.arguments.dropFirst()) - let verbose = args.contains("-v") || args.contains("--verbose") - - // Parse output file option + var verbose = false + var confirmLiveAI = false + var includeAskFollowUp = false + var freshChatCount = 1 var outputFile: String? - for (index, arg) in args.enumerated() { - if arg == "-o" || arg == "--output", index + 1 < args.count { - outputFile = args[index + 1] - break - } - } + var bodyFile: String? + var filteredArgs: [String] = [] - // Parse authuser option - for (index, arg) in args.enumerated() { - if arg == "--authuser", index + 1 < args.count { - if let value = Int(args[index + 1]) { - globalAuthUserIndex = value + var index = 0 + while index < args.count { + let argument = args[index] + switch argument { + case "-v", "--verbose": + verbose = true + case "--youtube", "--yt": + activateYouTubeMode() + case "--no-auth", "--guest": + forceUnauthenticatedRequests = true + case "--confirm-live-ai": + confirmLiveAI = true + case "--follow-up": + includeAskFollowUp = true + case "-o", "--output": + guard let value = commandLineOptionValue( + after: index, + in: args, + allowSingleDash: true + ) else { + print("❌ \(argument) requires a path") + return } - break - } - } - - // Parse brand account option - for (index, arg) in args.enumerated() { - if arg == "--brand", index + 1 < args.count { - globalBrandAccountId = args[index + 1] - break - } - } - - // Parse client version override. Keeping it in cachedClientVersion prevents - // live web configuration discovery from replacing the explicit probe value. - for (index, arg) in args.enumerated() { - guard arg == "--client-version" else { continue } - guard index + 1 < args.count else { - print("❌ --client-version requires a value") - return - } - - let value = args[index + 1].trimmingCharacters(in: .whitespacesAndNewlines) - guard !value.isEmpty, !value.hasPrefix("-") else { - print("❌ Invalid --client-version value: provide a version such as 1.20231204.01.00") - return - } - - cachedClientVersion = value - clientVersionWasForced = true - break - } - - // Parse YouTube mode option (target www.youtube.com / WEB client) - if args.contains("--youtube") || args.contains("--yt") { - activateYouTubeMode() - } - - // Parse guest/no-auth option before filtering so cookie-backed auth checks - // behave as if no Kaset debug cookie export exists. This is useful for - // validating public signed-out API behavior on a developer machine that is - // normally signed in. - if args.contains("--no-auth") || args.contains("--guest") { - forceUnauthenticatedRequests = true - } - - // Filter out option flags and their values - var filteredArgs: [String] = [] - var skipNext = false - for arg in args { - if skipNext { - skipNext = false - continue - } - if arg == "-v" || arg == "--verbose" || arg == "--youtube" || arg == "--yt" - || arg == "--no-auth" || arg == "--guest" - { - continue - } - if arg == "-o" || arg == "--output" || arg == "--authuser" || arg == "--brand" - || arg == "--client-version" - { - skipNext = true + index += 1 + outputFile = value + case "--body-file": + guard let value = commandLineOptionValue( + after: index, + in: args, + allowSingleDash: true + ) else { + print("❌ --body-file requires a path or -") + return + } + index += 1 + bodyFile = value + case "--authuser": + guard let rawValue = commandLineOptionValue(after: index, in: args), + let value = Int(rawValue), + value >= 0 + else { + print("❌ --authuser requires a nonnegative integer") + return + } + index += 1 + authUserOptionWasSpecified = true + globalAuthUserIndex = value + case "--brand": + guard let value = commandLineOptionValue(after: index, in: args) else { + print("❌ --brand requires an account ID") + return + } + index += 1 + globalBrandAccountId = value + case "--client-version": + guard let rawValue = commandLineOptionValue(after: index, in: args) else { + print("❌ --client-version requires a value") + return + } + index += 1 + let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty, !value.hasPrefix("-") else { + print("❌ Invalid --client-version value: provide a version such as 1.20231204.01.00") + return + } + cachedClientVersion = value + clientVersionWasForced = true + case "--fresh-chats": + guard let rawValue = commandLineOptionValue(after: index, in: args), + let value = Int(rawValue), + (1 ... 3).contains(value) + else { + print("❌ --fresh-chats requires an integer between 1 and 3") + return + } + index += 1 + freshChatCount = value + case "--": + filteredArgs.append(contentsOf: args.dropFirst(index + 1)) + index = args.count continue + default: + filteredArgs.append(argument) } - filteredArgs.append(arg) + index += 1 } guard let command = filteredArgs.first else { @@ -2731,14 +3525,104 @@ func runMain() async { await exploreBrowse(browseId, params: params, verbose: verbose, outputFile: outputFile) case "action": - guard filteredArgs.count >= 3 else { - print("❌ Usage: action ") - print(" Example: action search '{\"query\":\"hello\"}'") + guard filteredArgs.count >= 2 else { + print("❌ Usage: action [body-json] [--body-file ]") + return + } + do { + let endpoint = try canonicalAPIEndpoint(filteredArgs[1]) + if requiresPrivateBodySource(endpoint) { + print("❌ \(endpoint) must use wire-action, not action") + print(" Sensitive panel responses are always summarized with raw values hidden.") + return + } + let bodyJson = try loadRequestBodyJSON( + inlineBody: filteredArgs.count >= 3 ? filteredArgs[2] : nil, + bodyFile: bodyFile + ) + await exploreAction( + endpoint, bodyJson: bodyJson, verbose: verbose, outputFile: outputFile + ) + } catch { + print("❌ \(error.localizedDescription)") + } + + case "wire-action": + guard filteredArgs.count >= 2 else { + print("❌ Usage: wire-action [body-json] [--body-file ]") + print(" Safely reports structure without printing raw response values.") + return + } + do { + let endpoint = try canonicalAPIEndpoint(filteredArgs[1]) + if requiresPrivateBodySource(endpoint), bodyFile == nil { + print("❌ \(endpoint) requires --body-file ") + print(" Opaque panel/conversation values must not be placed in argv or shell history.") + return + } + let bodyJson = try loadRequestBodyJSON( + inlineBody: filteredArgs.count >= 3 ? filteredArgs[2] : nil, + bodyFile: bodyFile + ) + await exploreWireAction( + endpoint, bodyJson: bodyJson, outputFile: outputFile + ) + } catch { + print("❌ \(error.localizedDescription)") + } + + case "ask-video-audit": + guard filteredArgs.count >= 2 else { + print("❌ Usage: ask-video-audit ") return } - let endpoint = filteredArgs[1] - let bodyJson = filteredArgs[2] - await exploreAction(endpoint, bodyJson: bodyJson, verbose: verbose, outputFile: outputFile) + if outputFile != nil { + print("⚠️ --output is ignored by ask-video-audit; the audit never saves raw payloads") + } + await auditAskVideo(filteredArgs[1], verbose: verbose) + + case "ask-video-parity": + guard filteredArgs.count >= 2 else { + print("❌ Usage: ask-video-parity ") + return + } + await auditAskVideoRequestParity( + filteredArgs[1], + hasUnsupportedOptions: filteredArgs.count != 2 + || outputFile != nil + || bodyFile != nil + || clientVersionWasForced + || includeAskFollowUp + || freshChatCount != 1 + ) + + case "ask-video-live-test": + guard filteredArgs.count == 2 else { + print("❌ Usage: ask-video-live-test --confirm-live-ai [--follow-up] [--fresh-chats N]") + return + } + guard confirmLiveAI else { + print("❌ ask-video-live-test requires --confirm-live-ai") + print(" This command sends the server-issued summary suggestion to YouTube.") + return + } + guard outputFile == nil, + bodyFile == nil, + !clientVersionWasForced, + !forceUnauthenticatedRequests, + !authUserOptionWasSpecified, + globalBrandAccountId == nil + else { + print("❌ ask-video-live-test received an unsupported authentication, client, or file option") + print(" Supported options: --confirm-live-ai, --follow-up, --fresh-chats N, --verbose") + return + } + await liveTestAskVideo( + filteredArgs[1], + freshChatCount: freshChatCount, + includeFollowUp: includeAskFollowUp, + verbose: verbose + ) case "search-audit": guard filteredArgs.count >= 2 else { diff --git a/Sources/YouTubeAskCore/YouTubeAskCoreError.swift b/Sources/YouTubeAskCore/YouTubeAskCoreError.swift new file mode 100644 index 000000000..eff704b60 --- /dev/null +++ b/Sources/YouTubeAskCore/YouTubeAskCoreError.swift @@ -0,0 +1,18 @@ +import Foundation + +package enum YouTubeAskCoreError: Error, Equatable, Sendable { + case decoderAlreadyFinished + case responseTooLarge + case emptyResponse + case frameTooLarge + case tooManyFrames + case malformedWireResponse + case duplicateJSONKey + case unsupportedJSONRoot + case structureLimitExceeded + case ambiguousBootstrap + case malformedChip + case unsupportedChipDecorator + case malformedMessage + case invalidClientMessageID +} diff --git a/Sources/YouTubeAskCore/YouTubeAskJSONDuplicateKeyValidator.swift b/Sources/YouTubeAskCore/YouTubeAskJSONDuplicateKeyValidator.swift new file mode 100644 index 000000000..e43260085 --- /dev/null +++ b/Sources/YouTubeAskCore/YouTubeAskJSONDuplicateKeyValidator.swift @@ -0,0 +1,250 @@ +import Foundation + +/// Validates JSON member uniqueness before Foundation materializes objects as +/// dictionaries, which would otherwise discard duplicate-key evidence. +enum YouTubeAskJSONDuplicateKeyValidator { + static func validate(_ data: Data) throws { + var parser = Parser(bytes: [UInt8](data)) + try parser.validate() + } + + private struct Parser { + let bytes: [UInt8] + var index = 0 + var visitedNodes = 0 + + mutating func validate() throws { + self.skipWhitespace() + try self.parseValue(depth: 0) + self.skipWhitespace() + guard self.index == self.bytes.count else { + throw YouTubeAskCoreError.malformedWireResponse + } + } + + private mutating func parseValue(depth: Int) throws { + guard self.index < self.bytes.count else { + throw YouTubeAskCoreError.malformedWireResponse + } + guard depth <= YouTubeAskLimits.maximumTreeDepth, + self.visitedNodes < YouTubeAskLimits.maximumTreeNodes + else { + throw YouTubeAskCoreError.structureLimitExceeded + } + self.visitedNodes += 1 + + switch self.bytes[self.index] { + case 0x7B: + try self.parseObject(depth: depth) + case 0x5B: + try self.parseArray(depth: depth) + case 0x22: + _ = try self.parseString() + case 0x74: + try self.consumeLiteral("true") + case 0x66: + try self.consumeLiteral("false") + case 0x6E: + try self.consumeLiteral("null") + case 0x2D, 0x30 ... 0x39: + try self.parseNumber() + default: + throw YouTubeAskCoreError.malformedWireResponse + } + } + + private mutating func parseObject(depth: Int) throws { + self.index += 1 + self.skipWhitespace() + if self.consumeIfPresent(0x7D) { + return + } + + var keys: Set = [] + var childCount = 0 + while true { + guard childCount < YouTubeAskLimits.maximumChildrenPerContainer else { + throw YouTubeAskCoreError.structureLimitExceeded + } + childCount += 1 + guard self.index < self.bytes.count, self.bytes[self.index] == 0x22 else { + throw YouTubeAskCoreError.malformedWireResponse + } + let key = try self.parseString() + guard keys.insert(key).inserted else { + throw YouTubeAskCoreError.duplicateJSONKey + } + + self.skipWhitespace() + guard self.consumeIfPresent(0x3A) else { + throw YouTubeAskCoreError.malformedWireResponse + } + self.skipWhitespace() + try self.parseValue(depth: depth + 1) + self.skipWhitespace() + + if self.consumeIfPresent(0x7D) { + return + } + guard self.consumeIfPresent(0x2C) else { + throw YouTubeAskCoreError.malformedWireResponse + } + self.skipWhitespace() + } + } + + private mutating func parseArray(depth: Int) throws { + self.index += 1 + self.skipWhitespace() + if self.consumeIfPresent(0x5D) { + return + } + + var childCount = 0 + while true { + guard childCount < YouTubeAskLimits.maximumChildrenPerContainer else { + throw YouTubeAskCoreError.structureLimitExceeded + } + childCount += 1 + try self.parseValue(depth: depth + 1) + self.skipWhitespace() + if self.consumeIfPresent(0x5D) { + return + } + guard self.consumeIfPresent(0x2C) else { + throw YouTubeAskCoreError.malformedWireResponse + } + self.skipWhitespace() + } + } + + private mutating func parseString() throws -> String { + let start = self.index + self.index += 1 + + while self.index < self.bytes.count { + let byte = self.bytes[self.index] + if byte == 0x22 { + self.index += 1 + let data = Data(self.bytes[start ..< self.index]) + guard let decoded = try JSONSerialization.jsonObject( + with: data, + options: [.fragmentsAllowed] + ) as? String else { + throw YouTubeAskCoreError.malformedWireResponse + } + return decoded + } + if byte == 0x5C { + self.index += 1 + guard self.index < self.bytes.count else { + throw YouTubeAskCoreError.malformedWireResponse + } + if self.bytes[self.index] == 0x75 { + guard self.index + 4 < self.bytes.count else { + throw YouTubeAskCoreError.malformedWireResponse + } + for offset in 1 ... 4 where !Self.isHexDigit(self.bytes[self.index + offset]) { + throw YouTubeAskCoreError.malformedWireResponse + } + self.index += 5 + } else { + self.index += 1 + } + continue + } + guard byte >= 0x20 else { + throw YouTubeAskCoreError.malformedWireResponse + } + self.index += 1 + } + throw YouTubeAskCoreError.malformedWireResponse + } + + private mutating func parseNumber() throws { + if self.consumeIfPresent(0x2D), self.index >= self.bytes.count { + throw YouTubeAskCoreError.malformedWireResponse + } + + if self.consumeIfPresent(0x30) { + if self.index < self.bytes.count, + (0x30 ... 0x39).contains(self.bytes[self.index]) + { + throw YouTubeAskCoreError.malformedWireResponse + } + } else { + try self.consumeDigits(requiringNonzeroFirst: true) + } + + if self.consumeIfPresent(0x2E) { + try self.consumeDigits(requiringNonzeroFirst: false) + } + if self.index < self.bytes.count, + self.bytes[self.index] == 0x65 || self.bytes[self.index] == 0x45 + { + self.index += 1 + if self.index < self.bytes.count, + self.bytes[self.index] == 0x2B || self.bytes[self.index] == 0x2D + { + self.index += 1 + } + try self.consumeDigits(requiringNonzeroFirst: false) + } + } + + private mutating func consumeDigits(requiringNonzeroFirst: Bool) throws { + guard self.index < self.bytes.count else { + throw YouTubeAskCoreError.malformedWireResponse + } + let first = self.bytes[self.index] + let validFirst = requiringNonzeroFirst + ? (0x31 ... 0x39).contains(first) + : (0x30 ... 0x39).contains(first) + guard validFirst else { + throw YouTubeAskCoreError.malformedWireResponse + } + self.index += 1 + while self.index < self.bytes.count, + (0x30 ... 0x39).contains(self.bytes[self.index]) + { + self.index += 1 + } + } + + private mutating func consumeLiteral(_ literal: StaticString) throws { + let literalBytes = Array(String(describing: literal).utf8) + guard literalBytes.count <= self.bytes.count - self.index, + self.bytes[self.index ..< self.index + literalBytes.count] + .elementsEqual(literalBytes) + else { + throw YouTubeAskCoreError.malformedWireResponse + } + self.index += literalBytes.count + } + + private mutating func skipWhitespace() { + while self.index < self.bytes.count { + switch self.bytes[self.index] { + case 0x09, 0x0A, 0x0D, 0x20: + self.index += 1 + default: + return + } + } + } + + private mutating func consumeIfPresent(_ byte: UInt8) -> Bool { + guard self.index < self.bytes.count, self.bytes[self.index] == byte else { + return false + } + self.index += 1 + return true + } + + private static func isHexDigit(_ byte: UInt8) -> Bool { + (0x30 ... 0x39).contains(byte) + || (0x41 ... 0x46).contains(byte) + || (0x61 ... 0x66).contains(byte) + } + } +} diff --git a/Sources/YouTubeAskCore/YouTubeAskJSONValue.swift b/Sources/YouTubeAskCore/YouTubeAskJSONValue.swift new file mode 100644 index 000000000..0e84cdcff --- /dev/null +++ b/Sources/YouTubeAskCore/YouTubeAskJSONValue.swift @@ -0,0 +1,30 @@ +import Foundation + +/// Sendable JSON representation used across the app and API Explorer targets. +package indirect enum YouTubeAskJSONValue: Equatable, Sendable { + case object([String: YouTubeAskJSONValue]) + case array([YouTubeAskJSONValue]) + case string(String) + case number(Double) + case bool(Bool) + case null + + package var objectValue: [String: YouTubeAskJSONValue]? { + guard case let .object(value) = self else { return nil } + return value + } + + package var arrayValue: [YouTubeAskJSONValue]? { + guard case let .array(value) = self else { return nil } + return value + } + + package var stringValue: String? { + guard case let .string(value) = self else { return nil } + return value + } + + package subscript(key: String) -> YouTubeAskJSONValue? { + self.objectValue?[key] + } +} diff --git a/Sources/YouTubeAskCore/YouTubeAskLimits.swift b/Sources/YouTubeAskCore/YouTubeAskLimits.swift new file mode 100644 index 000000000..aeabdb66d --- /dev/null +++ b/Sources/YouTubeAskCore/YouTubeAskLimits.swift @@ -0,0 +1,14 @@ +import Foundation + +package enum YouTubeAskLimits { + package static let maximumResponseBytes = 32 * 1024 * 1024 + package static let maximumFrameBytes = 4 * 1024 * 1024 + package static let maximumFrames = 256 + package static let maximumChipCharacters = 200 + package static let maximumAnswerCharacters = 16000 + + static let maximumTreeDepth = 80 + static let maximumTreeNodes = 100_000 + static let maximumChildrenPerContainer = 2048 + static let maximumCommandCharacters = 64 * 1024 +} diff --git a/Sources/YouTubeAskCore/YouTubeAskOpaqueCommand.swift b/Sources/YouTubeAskCore/YouTubeAskOpaqueCommand.swift new file mode 100644 index 000000000..aad9ced66 --- /dev/null +++ b/Sources/YouTubeAskCore/YouTubeAskOpaqueCommand.swift @@ -0,0 +1,33 @@ +import Foundation + +// MARK: - YouTubeAskOpaqueCommand + +/// Server-issued command material. The raw value is intentionally inaccessible +/// outside this module and has only redacted string/reflection representations. +package struct YouTubeAskOpaqueCommand: Sendable { + let continuation: String +} + +// MARK: CustomStringConvertible + +extension YouTubeAskOpaqueCommand: CustomStringConvertible { + package var description: String { + "" + } +} + +// MARK: CustomDebugStringConvertible + +extension YouTubeAskOpaqueCommand: CustomDebugStringConvertible { + package var debugDescription: String { + self.description + } +} + +// MARK: CustomReflectable + +extension YouTubeAskOpaqueCommand: CustomReflectable { + package var customMirror: Mirror { + Mirror(reflecting: self.description) + } +} diff --git a/Sources/YouTubeAskCore/YouTubeAskParsedModels.swift b/Sources/YouTubeAskCore/YouTubeAskParsedModels.swift new file mode 100644 index 000000000..7189c7d06 --- /dev/null +++ b/Sources/YouTubeAskCore/YouTubeAskParsedModels.swift @@ -0,0 +1,29 @@ +import Foundation + +// MARK: - YouTubeAskParsedSuggestion + +package struct YouTubeAskParsedSuggestion: Sendable { + package let label: String + package let command: YouTubeAskOpaqueCommand +} + +// MARK: - YouTubeAskParsedBootstrap + +package struct YouTubeAskParsedBootstrap: Sendable { + package let panelCommand: YouTubeAskOpaqueCommand? + package let suggestions: [YouTubeAskParsedSuggestion] +} + +// MARK: - YouTubeAskParsedMessage + +package struct YouTubeAskParsedMessage: Sendable { + package let text: String + package let wasTruncated: Bool +} + +// MARK: - YouTubeAskParsedConversation + +package struct YouTubeAskParsedConversation: Sendable { + package let messages: [YouTubeAskParsedMessage] + package let suggestions: [YouTubeAskParsedSuggestion] +} diff --git a/Sources/YouTubeAskCore/YouTubeAskParser.swift b/Sources/YouTubeAskCore/YouTubeAskParser.swift new file mode 100644 index 000000000..560b8373c --- /dev/null +++ b/Sources/YouTubeAskCore/YouTubeAskParser.swift @@ -0,0 +1,536 @@ +import Foundation + +package enum YouTubeAskParser { + /// Parses a watch `next` response. `nil` means the response did not expose a + /// usable YouChat panel bootstrap or direct server-issued suggestions. + package static func parseBootstrap( + from envelope: YouTubeAskWireEnvelope + ) throws -> YouTubeAskParsedBootstrap? { + var panelBudget = TraversalBudget() + var eligiblePanels: [YouTubeAskJSONValue] = [] + for root in envelope.roots { + try Self.collectEligiblePanels( + in: root, + depth: 0, + budget: &panelBudget, + panels: &eligiblePanels + ) + } + guard !eligiblePanels.isEmpty else { return nil } + + var continuationCandidates: [String] = [] + var content = ConversationAccumulator() + for panel in eligiblePanels { + var commandBudget = TraversalBudget() + try Self.collectBootstrapContinuations( + in: panel, + depth: 0, + insideSendUserQueryCommand: false, + budget: &commandBudget, + continuations: &continuationCandidates + ) + + var contentBudget = TraversalBudget() + try Self.collectBootstrapSuggestions( + in: panel, + depth: 0, + budget: &contentBudget, + content: &content + ) + } + + let panelContinuation = try Self.unambiguousContinuation(continuationCandidates) + guard panelContinuation != nil || !content.suggestions.isEmpty else { return nil } + return YouTubeAskParsedBootstrap( + panelCommand: panelContinuation.map(YouTubeAskOpaqueCommand.init), + suggestions: content.suggestions + ) + } + + /// Parses assistant messages and server-issued follow-up suggestions from a + /// materialized panel or direct-chip response. + package static func parseConversation( + from envelope: YouTubeAskWireEnvelope + ) throws -> YouTubeAskParsedConversation { + var budget = TraversalBudget() + for root in envelope.roots { + try Self.validateStructure( + in: root, + depth: 0, + budget: &budget + ) + } + + var content = ConversationAccumulator() + for root in envelope.roots { + try Self.collectConfirmedConversationContent( + from: root, + content: &content + ) + } + return YouTubeAskParsedConversation( + messages: content.messages, + suggestions: content.suggestions + ) + } + + private struct ConversationAccumulator { + var messages: [YouTubeAskParsedMessage] = [] + var suggestions: [YouTubeAskParsedSuggestion] = [] + } + + private struct TraversalBudget { + var visitedNodes = 0 + + mutating func visit(_ value: YouTubeAskJSONValue, depth: Int) throws { + let childCount = switch value { + case let .object(object): + object.count + case let .array(array): + array.count + default: + 0 + } + guard depth <= YouTubeAskLimits.maximumTreeDepth, + self.visitedNodes < YouTubeAskLimits.maximumTreeNodes, + childCount <= YouTubeAskLimits.maximumChildrenPerContainer + else { + throw YouTubeAskCoreError.structureLimitExceeded + } + self.visitedNodes += 1 + } + } + + private static let eligibleMarkerValues: Set = [ + "PAyouchat", + "engagement-panel-youchat", + ] + + private static let eligibleMarkerKeys: Set = [ + "identifier", + "panelId", + "panelIdentifier", + "targetId", + ] + + private static func collectEligiblePanels( + in value: YouTubeAskJSONValue, + depth: Int, + budget: inout TraversalBudget, + panels: inout [YouTubeAskJSONValue] + ) throws { + try budget.visit(value, depth: depth) + switch value { + case let .object(object): + if Self.isEligiblePanelObject(object) { + panels.append(value) + return + } + for key in object.keys.sorted() { + guard let nested = object[key] else { continue } + if key == "PAyouchat" { + panels.append(nested) + continue + } + try Self.collectEligiblePanels( + in: nested, + depth: depth + 1, + budget: &budget, + panels: &panels + ) + } + case let .array(array): + for nested in array { + try Self.collectEligiblePanels( + in: nested, + depth: depth + 1, + budget: &budget, + panels: &panels + ) + } + default: + break + } + } + + private static func isEligiblePanelObject( + _ object: [String: YouTubeAskJSONValue] + ) -> Bool { + object.contains { key, value in + Self.eligibleMarkerKeys.contains(key) + && value.stringValue.map(Self.eligibleMarkerValues.contains) == true + } + } + + private static func collectBootstrapContinuations( + in value: YouTubeAskJSONValue, + depth: Int, + insideSendUserQueryCommand: Bool, + budget: inout TraversalBudget, + continuations: inout [String] + ) throws { + try budget.visit(value, depth: depth) + switch value { + case let .object(object): + if !insideSendUserQueryCommand, + object["request"]?.stringValue == "CONTINUATION_REQUEST_TYPE_GET_PANEL", + let token = object["token"]?.stringValue, + !token.isEmpty, + token.count <= YouTubeAskLimits.maximumCommandCharacters + { + continuations.append(token) + } + + for key in object.keys.sorted() { + guard let nested = object[key] else { continue } + try Self.collectBootstrapContinuations( + in: nested, + depth: depth + 1, + insideSendUserQueryCommand: insideSendUserQueryCommand + || key == "sendUserQueryCommand", + budget: &budget, + continuations: &continuations + ) + } + case let .array(array): + for nested in array { + try Self.collectBootstrapContinuations( + in: nested, + depth: depth + 1, + insideSendUserQueryCommand: insideSendUserQueryCommand, + budget: &budget, + continuations: &continuations + ) + } + default: + break + } + } + + private static func unambiguousContinuation( + _ candidates: [String] + ) throws -> String? { + guard let first = candidates.first else { return nil } + guard candidates.dropFirst().allSatisfy({ $0 == first }) else { + throw YouTubeAskCoreError.ambiguousBootstrap + } + return first + } + + private static func validateStructure( + in value: YouTubeAskJSONValue, + depth: Int, + budget: inout TraversalBudget + ) throws { + try budget.visit(value, depth: depth) + switch value { + case let .object(object): + for key in object.keys.sorted() { + guard let nested = object[key] else { continue } + try Self.validateStructure( + in: nested, + depth: depth + 1, + budget: &budget + ) + } + case let .array(array): + for nested in array { + try Self.validateStructure( + in: nested, + depth: depth + 1, + budget: &budget + ) + } + default: + break + } + } + + private static func collectConfirmedConversationContent( + from root: YouTubeAskJSONValue, + content: inout ConversationAccumulator + ) throws { + switch root { + case let .object(response): + try Self.parseConfirmedConversationResponse( + response, + content: &content + ) + case let .array(responses): + for response in responses { + guard let object = response.objectValue else { continue } + try Self.parseConfirmedConversationResponse( + object, + content: &content + ) + } + default: + break + } + } + + /// Accept only the response path observed for YouChat panel materialization: + /// `onResponseReceivedCommands[].appendContinuationItemsAction.continuationItems[]`. + private static func parseConfirmedConversationResponse( + _ response: [String: YouTubeAskJSONValue], + content: inout ConversationAccumulator + ) throws { + guard let commandsValue = response["onResponseReceivedCommands"] else { return } + guard let commands = commandsValue.arrayValue else { + throw YouTubeAskCoreError.malformedWireResponse + } + + for command in commands { + guard let commandObject = command.objectValue else { + throw YouTubeAskCoreError.malformedWireResponse + } + guard let appendAction = commandObject["appendContinuationItemsAction"] else { + continue + } + try Self.parseConfirmedAppendAction( + appendAction, + content: &content + ) + } + } + + private static func parseConfirmedAppendAction( + _ value: YouTubeAskJSONValue, + content: inout ConversationAccumulator + ) throws { + guard let action = value.objectValue, + let continuationItems = action["continuationItems"]?.arrayValue + else { + throw YouTubeAskCoreError.malformedWireResponse + } + + for item in continuationItems { + try Self.parseConfirmedContinuationItem( + item, + content: &content + ) + } + } + + private static func parseConfirmedContinuationItem( + _ value: YouTubeAskJSONValue, + content: inout ConversationAccumulator + ) throws { + guard let item = value.objectValue else { return } + let recognizedKeys = [ + "youChatTextMessageViewModel", + "youChatItemViewModel", + ].filter { item[$0] != nil } + guard recognizedKeys.count <= 1 else { + throw YouTubeAskCoreError.malformedWireResponse + } + guard let key = recognizedKeys.first, + let viewModel = item[key] + else { + return + } + + switch key { + case "youChatTextMessageViewModel": + try content.messages.append(Self.parseMessageViewModel(viewModel)) + case "youChatItemViewModel": + try Self.parseYouChatItemViewModel( + viewModel, + includeMessages: true, + content: &content + ) + default: + break + } + } + + private static func collectBootstrapSuggestions( + in value: YouTubeAskJSONValue, + depth: Int, + budget: inout TraversalBudget, + content: inout ConversationAccumulator + ) throws { + try budget.visit(value, depth: depth) + switch value { + case let .object(object): + for key in object.keys.sorted() { + guard let nested = object[key] else { continue } + if key == "youChatItemViewModel" { + try Self.parseYouChatItemViewModel( + nested, + includeMessages: false, + content: &content + ) + } + + try Self.collectBootstrapSuggestions( + in: nested, + depth: depth + 1, + budget: &budget, + content: &content + ) + } + case let .array(array): + for nested in array { + try Self.collectBootstrapSuggestions( + in: nested, + depth: depth + 1, + budget: &budget, + content: &content + ) + } + default: + break + } + } + + private static func parseYouChatItemViewModel( + _ value: YouTubeAskJSONValue, + includeMessages: Bool, + content: inout ConversationAccumulator + ) throws { + guard let viewModel = value.objectValue else { return } + + if let chipsData = viewModel["chipsData"] { + try content.suggestions.append(contentsOf: Self.parseChipsData(chipsData)) + } + if includeMessages, + viewModel["chipsData"] == nil, + viewModel["text"] != nil + { + try content.messages.append(Self.parseMessageViewModel(value)) + } + } + + private static func parseChipsData( + _ value: YouTubeAskJSONValue + ) throws -> [YouTubeAskParsedSuggestion] { + guard let chipsData = value.objectValue, + let chipData = chipsData["chipData"]?.arrayValue + else { + throw YouTubeAskCoreError.malformedChip + } + return try chipData.map(Self.parseChip) + } + + private static func parseChip( + _ value: YouTubeAskJSONValue + ) throws -> YouTubeAskParsedSuggestion { + guard let chip = value.objectValue else { + throw YouTubeAskCoreError.malformedChip + } + if Self.containsUnsupportedChipDecorator(value) { + throw YouTubeAskCoreError.unsupportedChipDecorator + } + guard let continuation = chip["continuation"]?.stringValue, + !continuation.isEmpty, + continuation.count <= YouTubeAskLimits.maximumCommandCharacters, + let textValue = chip["text"] + else { + throw YouTubeAskCoreError.malformedChip + } + + let visibleText = try Self.visibleText( + from: textValue, + malformedError: .malformedChip + ) + guard let label = YouTubeAskVisibleTextSanitizer.sanitizeChipLabel(visibleText) else { + throw YouTubeAskCoreError.malformedChip + } + return YouTubeAskParsedSuggestion( + label: label.text, + command: YouTubeAskOpaqueCommand(continuation: continuation) + ) + } + + private static func parseMessageViewModel( + _ value: YouTubeAskJSONValue + ) throws -> YouTubeAskParsedMessage { + guard let viewModel = value.objectValue, + let textValue = viewModel["text"] + else { + throw YouTubeAskCoreError.malformedMessage + } + let visibleText = try Self.visibleText( + from: textValue, + malformedError: .malformedMessage + ) + guard let message = YouTubeAskVisibleTextSanitizer.sanitizeAnswer(visibleText) else { + throw YouTubeAskCoreError.malformedMessage + } + return YouTubeAskParsedMessage( + text: message.text, + wasTruncated: message.wasTruncated + ) + } + + private static func visibleText( + from value: YouTubeAskJSONValue, + malformedError: YouTubeAskCoreError + ) throws -> String { + if let string = value.stringValue { + return string + } + guard let object = value.objectValue else { + throw malformedError + } + + let recognizedKeys = ["content", "simpleText", "runs"] + .filter { object[$0] != nil } + guard recognizedKeys.count == 1, let selectedKey = recognizedKeys.first else { + throw malformedError + } + + switch selectedKey { + case "content", "simpleText": + guard let text = object[selectedKey]?.stringValue else { + throw malformedError + } + return text + case "runs": + guard let runs = object["runs"]?.arrayValue, !runs.isEmpty else { + throw malformedError + } + var text = "" + for run in runs { + guard let runObject = run.objectValue, + let runText = runObject["text"]?.stringValue + else { + throw malformedError + } + text.append(contentsOf: runText) + } + return text + default: + throw malformedError + } + } + + private static func containsUnsupportedChipDecorator( + _ value: YouTubeAskJSONValue + ) -> Bool { + switch value { + case let .object(object): + for (key, nested) in object { + let canonical = key.lowercased() + if canonical == "onclick" + || canonical == "innertubecommand" + || canonical == "commandmetadata" + || canonical.hasSuffix("command") + || canonical.hasSuffix("endpoint") + || canonical.contains("decorator") + { + return true + } + if Self.containsUnsupportedChipDecorator(nested) { + return true + } + } + return false + case let .array(array): + return array.contains(where: Self.containsUnsupportedChipDecorator) + default: + return false + } + } +} diff --git a/Sources/YouTubeAskCore/YouTubeAskRequestBuilder.swift b/Sources/YouTubeAskCore/YouTubeAskRequestBuilder.swift new file mode 100644 index 000000000..0656b02b7 --- /dev/null +++ b/Sources/YouTubeAskCore/YouTubeAskRequestBuilder.swift @@ -0,0 +1,65 @@ +import Foundation + +package enum YouTubeAskRequestBuilder { + package static func makePanelBootstrapBody( + command: YouTubeAskOpaqueCommand + ) -> Data { + do { + return try JSONEncoder().encode( + PanelBootstrapBody(continuation: command.continuation) + ) + } catch { + preconditionFailure("Could not encode the fixed YouTube Ask bootstrap body") + } + } + + package static func makeDirectChipBody( + command: YouTubeAskOpaqueCommand, + clientMessageID: String + ) throws -> Data { + guard self.isValidClientMessageID(clientMessageID) else { + throw YouTubeAskCoreError.invalidClientMessageID + } + + let body = DirectChipBody( + continuation: command.continuation, + formData: FormData( + inputComposerFormData: InputComposerFormData( + clientMessageId: clientMessageID + ) + ) + ) + return try JSONEncoder().encode(body) + } + + private static func isValidClientMessageID(_ value: String) -> Bool { + let bytes = Array(value.utf8) + let prefix = Array("youchat-".utf8) + guard bytes.count <= 64, + bytes.count > prefix.count, + bytes.starts(with: prefix) + else { + return false + } + return bytes.dropFirst(prefix.count).allSatisfy { byte in + (0x30 ... 0x39).contains(byte) + } + } + + private struct PanelBootstrapBody: Encodable { + let continuation: String + } + + private struct DirectChipBody: Encodable { + let continuation: String + let formData: FormData + } + + private struct FormData: Encodable { + let inputComposerFormData: InputComposerFormData + } + + private struct InputComposerFormData: Encodable { + let clientMessageId: String + } +} diff --git a/Sources/YouTubeAskCore/YouTubeAskRequestProfile.swift b/Sources/YouTubeAskCore/YouTubeAskRequestProfile.swift new file mode 100644 index 000000000..a25cf77be --- /dev/null +++ b/Sources/YouTubeAskCore/YouTubeAskRequestProfile.swift @@ -0,0 +1,59 @@ +import Foundation + +/// A credential-free description of one YouTube Ask request configuration. +/// +/// Boolean fields describe which runtime values the adapter should attach. The +/// values themselves never enter this shared core model. +package struct YouTubeAskRequestProfile: Equatable, Sendable { + package static let productionClientVersion = "2.20260611.01.00" + + /// The request profile used by normal `YouTubeClient` requests today. + package static let fixedProduction = YouTubeAskRequestProfile( + clientVersion: Self.productionClientVersion, + includesRuntimeAPIParameter: false, + usesVisitorData: false, + usesAllSIDProofs: false + ) + + /// The second parity probe: production configuration with every available + /// SID proof scheme enabled by the adapter. + package static let fixedProductionWithAllSIDProofs = YouTubeAskRequestProfile( + clientVersion: Self.productionClientVersion, + includesRuntimeAPIParameter: false, + usesVisitorData: false, + usesAllSIDProofs: true + ) + + package let clientVersion: String + package let includesRuntimeAPIParameter: Bool + package let usesVisitorData: Bool + package let usesAllSIDProofs: Bool + + package init( + clientVersion: String, + includesRuntimeAPIParameter: Bool, + usesVisitorData: Bool, + usesAllSIDProofs: Bool + ) { + self.clientVersion = clientVersion + self.includesRuntimeAPIParameter = includesRuntimeAPIParameter + self.usesVisitorData = usesVisitorData + self.usesAllSIDProofs = usesAllSIDProofs + } + + /// Ordered profiles for read-only request parity validation. + package static func orderedParityProfiles( + runtimeClientVersion: String + ) -> [YouTubeAskRequestProfile] { + [ + self.fixedProduction, + self.fixedProductionWithAllSIDProofs, + YouTubeAskRequestProfile( + clientVersion: runtimeClientVersion, + includesRuntimeAPIParameter: true, + usesVisitorData: true, + usesAllSIDProofs: true + ), + ] + } +} diff --git a/Sources/YouTubeAskCore/YouTubeAskVisibleTextSanitizer.swift b/Sources/YouTubeAskCore/YouTubeAskVisibleTextSanitizer.swift new file mode 100644 index 000000000..90cc080f3 --- /dev/null +++ b/Sources/YouTubeAskCore/YouTubeAskVisibleTextSanitizer.swift @@ -0,0 +1,282 @@ +import Foundation + +// MARK: - YouTubeAskSanitizedText + +package struct YouTubeAskSanitizedText: Equatable, Sendable { + package let text: String + package let wasTruncated: Bool +} + +// MARK: - YouTubeAskVisibleTextSanitizer + +package enum YouTubeAskVisibleTextSanitizer { + package static func sanitizeChipLabel(_ value: String) -> YouTubeAskSanitizedText? { + guard let sanitized = sanitize(value), + sanitized.count <= YouTubeAskLimits.maximumChipCharacters + else { + return nil + } + return YouTubeAskSanitizedText(text: sanitized, wasTruncated: false) + } + + package static func sanitizeAnswer(_ value: String) -> YouTubeAskSanitizedText? { + guard let sanitized = sanitize(value) else { return nil } + let wasTruncated = sanitized.count > YouTubeAskLimits.maximumAnswerCharacters + let text = wasTruncated + ? String(sanitized.prefix(YouTubeAskLimits.maximumAnswerCharacters)) + : sanitized + return YouTubeAskSanitizedText(text: text, wasTruncated: wasTruncated) + } + + private static let bidirectionalControlValues: Set = [ + 0x061C, + 0x200E, + 0x200F, + 0x202A, + 0x202B, + 0x202C, + 0x202D, + 0x202E, + 0x2066, + 0x2067, + 0x2068, + 0x2069, + ] + + private static func sanitize(_ value: String) -> String? { + var result = Self.removingTerminalAndControlSequences(value) + result = Self.replacingMarkdownLinks(in: result) + result = Self.replacingLinks(in: result) + result = Self.replacingHighEntropyIdentifiers(in: result) + result = result.trimmingCharacters(in: .whitespacesAndNewlines) + return result.isEmpty ? nil : result + } + + private static func removingTerminalAndControlSequences(_ value: String) -> String { + let scalars = Array(value.unicodeScalars) + var result = String.UnicodeScalarView() + result.reserveCapacity(scalars.count) + var index = 0 + + while index < scalars.count { + let scalar = scalars[index] + switch scalar.value { + case 0x1B: + index = Self.indexAfterEscapeSequence(in: scalars, startingAt: index) + case 0x9B: + index = Self.indexAfterControlSequence(in: scalars, startingAt: index + 1) + case 0x90, 0x98, 0x9D, 0x9E, 0x9F: + index = Self.indexAfterStringControl(in: scalars, startingAt: index + 1) + default: + if Self.bidirectionalControlValues.contains(scalar.value) + || Self.isDisallowedControl(scalar) + { + index += 1 + } else { + result.append(scalar) + index += 1 + } + } + } + + return String(result) + } + + private static func indexAfterEscapeSequence( + in scalars: [UnicodeScalar], + startingAt index: Int + ) -> Int { + let nextIndex = index + 1 + guard nextIndex < scalars.count else { return scalars.count } + + switch scalars[nextIndex].value { + case 0x5B: + return Self.indexAfterControlSequence(in: scalars, startingAt: nextIndex + 1) + case 0x50, 0x58, 0x5D, 0x5E, 0x5F: + return Self.indexAfterStringControl(in: scalars, startingAt: nextIndex + 1) + default: + return min(nextIndex + 1, scalars.count) + } + } + + private static func indexAfterControlSequence( + in scalars: [UnicodeScalar], + startingAt index: Int + ) -> Int { + var cursor = index + while cursor < scalars.count { + let value = scalars[cursor].value + cursor += 1 + if (0x40 ... 0x7E).contains(value) { + return cursor + } + } + return scalars.count + } + + private static func indexAfterStringControl( + in scalars: [UnicodeScalar], + startingAt index: Int + ) -> Int { + var cursor = index + while cursor < scalars.count { + if scalars[cursor].value == 0x07 { + return cursor + 1 + } + if scalars[cursor].value == 0x1B, + cursor + 1 < scalars.count, + scalars[cursor + 1].value == 0x5C + { + return cursor + 2 + } + cursor += 1 + } + return scalars.count + } + + private static func isDisallowedControl(_ scalar: UnicodeScalar) -> Bool { + switch scalar.value { + case 0x09, 0x0A: + false + case 0x00 ... 0x1F, 0x7F ... 0x9F: + true + default: + false + } + } + + private static func replacingMarkdownLinks(in value: String) -> String { + self.replacingMatches( + in: value, + pattern: #"(?i)\[([^\]\r\n]{1,500})\]\(\s*https?://[^\s)]+\s*\)"#, + template: "$1" + ) + } + + private static func replacingLinks(in value: String) -> String { + let withoutAutoLinks = Self.replacingMatches( + in: value, + pattern: #"(?i)]+>"#, + template: "[link omitted]" + ) + let withoutHTTPLinks = Self.replacingMatches( + in: withoutAutoLinks, + pattern: #"(?i)https?://[^\s<>()\[\]{}]+"#, + template: "[link omitted]" + ) + return Self.replacingMatches( + in: withoutHTTPLinks, + pattern: #"(?i)(?()\[\]{}]+"#, + template: "[link omitted]" + ) + } + + private static func replacingMatches( + in value: String, + pattern: String, + template: String + ) -> String { + guard let expression = try? NSRegularExpression(pattern: pattern) else { return value } + let range = NSRange(value.startIndex ..< value.endIndex, in: value) + return expression.stringByReplacingMatches( + in: value, + options: [], + range: range, + withTemplate: template + ) + } + + private static func replacingHighEntropyIdentifiers(in value: String) -> String { + var result = "" + var candidateScalars = String.UnicodeScalarView() + + func appendToken() { + guard !candidateScalars.isEmpty else { return } + let candidate = String(candidateScalars) + result.append(contentsOf: Self.isLikelyOpaqueIdentifier(candidate) + ? "[opaque omitted]" + : candidate) + candidateScalars.removeAll(keepingCapacity: true) + } + + for scalar in value.unicodeScalars { + if Self.isOpaqueTokenScalar(scalar) { + candidateScalars.append(scalar) + } else { + appendToken() + result.unicodeScalars.append(scalar) + } + } + appendToken() + return result + } + + private static func isOpaqueTokenScalar(_ scalar: UnicodeScalar) -> Bool { + switch scalar.value { + case 0x2D, 0x2E, 0x30 ... 0x39, 0x3D, 0x41 ... 0x5A, 0x5F, 0x61 ... 0x7A: + true + default: + false + } + } + + private static func isLikelyOpaqueIdentifier(_ value: String) -> Bool { + if self.isUUIDLike(value) { + return true + } + + let scalars = value.unicodeScalars.filter { $0.value != 0x3D } + guard scalars.count >= 48 else { return false } + + let isHex = scalars.allSatisfy { scalar in + (0x30 ... 0x39).contains(scalar.value) + || (0x41 ... 0x46).contains(scalar.value) + || (0x61 ... 0x66).contains(scalar.value) + } + let distinctValues = Set(scalars.map(\.value)) + if isHex, scalars.count >= 40, distinctValues.count >= 8 { + return true + } + + var hasLowercase = false + var hasUppercase = false + var hasDigit = false + var hasSeparator = false + var frequencies: [UInt32: Int] = [:] + for scalar in scalars { + frequencies[scalar.value, default: 0] += 1 + switch scalar.value { + case 0x30 ... 0x39: + hasDigit = true + case 0x41 ... 0x5A: + hasUppercase = true + case 0x61 ... 0x7A: + hasLowercase = true + default: + hasSeparator = true + } + } + + let categoryCount = [hasLowercase, hasUppercase, hasDigit, hasSeparator] + .filter(\.self) + .count + let maximumFrequency = frequencies.values.max() ?? scalars.count + let isWellDistributed = maximumFrequency * 4 <= scalars.count + if distinctValues.count >= 12, categoryCount >= 2, isWellDistributed { + return true + } + return scalars.count >= 64 + && distinctValues.count >= 16 + && maximumFrequency * 5 <= scalars.count + } + + private static func isUUIDLike(_ value: String) -> Bool { + let parts = value.split(separator: "-", omittingEmptySubsequences: false) + guard parts.map(\.count) == [8, 4, 4, 4, 12] else { return false } + return parts.joined().unicodeScalars.allSatisfy { scalar in + (0x30 ... 0x39).contains(scalar.value) + || (0x41 ... 0x46).contains(scalar.value) + || (0x61 ... 0x66).contains(scalar.value) + } + } +} diff --git a/Sources/YouTubeAskCore/YouTubeAskWireDecoder.swift b/Sources/YouTubeAskCore/YouTubeAskWireDecoder.swift new file mode 100644 index 000000000..0b25907f3 --- /dev/null +++ b/Sources/YouTubeAskCore/YouTubeAskWireDecoder.swift @@ -0,0 +1,359 @@ +import Foundation + +// MARK: - YouTubeAskWireEnvelope + +package struct YouTubeAskWireEnvelope: Sendable { + package enum Format: Equatable, Sendable { + case jsonObject + case jsonArray + case newlineDelimitedJSON + case lengthPrefixedJSON + } + + package let format: Format + package let hadXSSIPrefix: Bool + package let roots: [YouTubeAskJSONValue] + + package var frameCount: Int { + self.roots.count + } +} + +// MARK: - YouTubeAskWireDecoder + +package struct YouTubeAskWireDecoder: Sendable { + private var buffer = Data() + private var didFinish = false + + package init() {} + + package static func decode(_ data: Data) throws -> YouTubeAskWireEnvelope { + var decoder = YouTubeAskWireDecoder() + try decoder.append(data) + return try decoder.finish() + } + + package mutating func append(_ data: Data) throws { + guard !self.didFinish else { + throw YouTubeAskCoreError.decoderAlreadyFinished + } + guard data.count <= YouTubeAskLimits.maximumResponseBytes - self.buffer.count else { + throw YouTubeAskCoreError.responseTooLarge + } + self.buffer.append(data) + } + + package mutating func finish() throws -> YouTubeAskWireEnvelope { + guard !self.didFinish else { + throw YouTubeAskCoreError.decoderAlreadyFinished + } + self.didFinish = true + + let prepared = Self.prepare([UInt8](self.buffer)) + let bytes = prepared.bytes + let plainJSONBytes = Self.trimASCIIWhitespace(bytes) + guard !plainJSONBytes.isEmpty else { + throw YouTubeAskCoreError.emptyResponse + } + + var budget = JSONTraversalBudget() + switch try Self.parseLengthPrefixedJSON(bytes, budget: &budget) { + case .notMatched: + break + case let .matched(roots): + return YouTubeAskWireEnvelope( + format: .lengthPrefixedJSON, + hadXSSIPrefix: prepared.hadXSSIPrefix, + roots: roots + ) + } + + if plainJSONBytes.count <= YouTubeAskLimits.maximumFrameBytes, + let root = try Self.attemptJSONFrame(Data(plainJSONBytes), budget: &budget) + { + let format: YouTubeAskWireEnvelope.Format = switch root { + case .object: + .jsonObject + case .array: + .jsonArray + default: + throw YouTubeAskCoreError.unsupportedJSONRoot + } + return YouTubeAskWireEnvelope( + format: format, + hadXSSIPrefix: prepared.hadXSSIPrefix, + roots: [root] + ) + } + + if let roots = try Self.parseNewlineDelimitedJSON(bytes, budget: &budget) { + return YouTubeAskWireEnvelope( + format: .newlineDelimitedJSON, + hadXSSIPrefix: prepared.hadXSSIPrefix, + roots: roots + ) + } + + if plainJSONBytes.count > YouTubeAskLimits.maximumFrameBytes { + throw YouTubeAskCoreError.frameTooLarge + } + throw YouTubeAskCoreError.malformedWireResponse + } + + private enum StreamMatch { + case notMatched + case matched([YouTubeAskJSONValue]) + } + + private struct PreparedBytes { + let bytes: [UInt8] + let hadXSSIPrefix: Bool + } + + private struct JSONTraversalBudget { + var visitedNodes = 0 + + mutating func visit( + depth: Int, + childCount: Int = 0 + ) throws { + guard depth <= YouTubeAskLimits.maximumTreeDepth, + self.visitedNodes < YouTubeAskLimits.maximumTreeNodes, + childCount <= YouTubeAskLimits.maximumChildrenPerContainer + else { + throw YouTubeAskCoreError.structureLimitExceeded + } + self.visitedNodes += 1 + } + } + + private static func prepare(_ bytes: [UInt8]) -> PreparedBytes { + var index = 0 + if bytes.starts(with: [0xEF, 0xBB, 0xBF]) { + index = 3 + } + Self.skipASCIIWhitespace(in: bytes, index: &index) + + let prefixes = [ + Array(")]}'".utf8), + Array("for(;;);".utf8), + Array("while(1);".utf8), + ] + let matchedPrefix = prefixes.first { prefix in + prefix.count <= bytes.count - index + && bytes[index ..< index + prefix.count].elementsEqual(prefix) + } + if let matchedPrefix { + index += matchedPrefix.count + if index < bytes.count, bytes[index] == 0x2C { + index += 1 + } + Self.skipASCIIWhitespace(in: bytes, index: &index) + } + + let remainder = index < bytes.count ? Array(bytes[index ..< bytes.count]) : [] + return PreparedBytes(bytes: remainder, hadXSSIPrefix: matchedPrefix != nil) + } + + private static func attemptJSONFrame( + _ data: Data, + budget: inout JSONTraversalBudget + ) throws -> YouTubeAskJSONValue? { + let raw: Any + do { + raw = try JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) + } catch { + return nil + } + guard raw is [String: Any] || raw is [Any] else { + throw YouTubeAskCoreError.unsupportedJSONRoot + } + try YouTubeAskJSONDuplicateKeyValidator.validate(data) + return try Self.convert(raw, depth: 0, budget: &budget) + } + + private static func decodeRequiredJSONFrame( + _ bytes: [UInt8], + budget: inout JSONTraversalBudget + ) throws -> YouTubeAskJSONValue { + guard bytes.count <= YouTubeAskLimits.maximumFrameBytes else { + throw YouTubeAskCoreError.frameTooLarge + } + guard let value = try attemptJSONFrame(Data(bytes), budget: &budget) else { + throw YouTubeAskCoreError.malformedWireResponse + } + return value + } + + private static func parseLengthPrefixedJSON( + _ bytes: [UInt8], + budget: inout JSONTraversalBudget + ) throws -> StreamMatch { + var index = 0 + var roots: [YouTubeAskJSONValue] = [] + + while true { + Self.skipASCIIWhitespace(in: bytes, index: &index) + if index >= bytes.count { + return roots.isEmpty ? .notMatched : .matched(roots) + } + if roots.count >= YouTubeAskLimits.maximumFrames { + throw YouTubeAskCoreError.tooManyFrames + } + + guard (0x30 ... 0x39).contains(bytes[index]) else { + if roots.isEmpty { + return .notMatched + } + throw YouTubeAskCoreError.malformedWireResponse + } + + let lineStart = index + while index < bytes.count, bytes[index] != 0x0A, bytes[index] != 0x0D { + index += 1 + } + guard index < bytes.count else { + if roots.isEmpty { + return .notMatched + } + throw YouTubeAskCoreError.malformedWireResponse + } + + let lengthBytes = Self.trimASCIIWhitespace(Array(bytes[lineStart ..< index])) + guard !lengthBytes.isEmpty, + lengthBytes.allSatisfy({ (0x30 ... 0x39).contains($0) }) + else { + throw YouTubeAskCoreError.malformedWireResponse + } + + var length = 0 + for byte in lengthBytes { + let digit = Int(byte - 0x30) + guard length <= (YouTubeAskLimits.maximumFrameBytes - digit) / 10 else { + throw YouTubeAskCoreError.frameTooLarge + } + length = length * 10 + digit + } + guard length > 0 else { + throw YouTubeAskCoreError.malformedWireResponse + } + + if bytes[index] == 0x0D { + index += 1 + if index < bytes.count, bytes[index] == 0x0A { + index += 1 + } + } else { + index += 1 + } + + guard length <= bytes.count - index else { + throw YouTubeAskCoreError.malformedWireResponse + } + let frameBytes = Array(bytes[index ..< index + length]) + try roots.append(Self.decodeRequiredJSONFrame(frameBytes, budget: &budget)) + index += length + } + } + + private static func parseNewlineDelimitedJSON( + _ bytes: [UInt8], + budget: inout JSONTraversalBudget + ) throws -> [YouTubeAskJSONValue]? { + var roots: [YouTubeAskJSONValue] = [] + var lineStart = 0 + var index = 0 + var sawLineBreak = false + + while index <= bytes.count { + if index < bytes.count, bytes[index] != 0x0A { + guard index - lineStart <= YouTubeAskLimits.maximumFrameBytes else { + throw YouTubeAskCoreError.frameTooLarge + } + index += 1 + continue + } + + if index < bytes.count { + sawLineBreak = true + } + let line = Self.trimASCIIWhitespace(Array(bytes[lineStart ..< index])) + if !line.isEmpty { + guard roots.count < YouTubeAskLimits.maximumFrames else { + throw YouTubeAskCoreError.tooManyFrames + } + try roots.append(Self.decodeRequiredJSONFrame(line, budget: &budget)) + } + + guard index < bytes.count else { break } + index += 1 + lineStart = index + } + + guard sawLineBreak, !roots.isEmpty else { return nil } + return roots + } + + private static func convert( + _ raw: Any, + depth: Int, + budget: inout JSONTraversalBudget + ) throws -> YouTubeAskJSONValue { + if let object = raw as? [String: Any] { + try budget.visit(depth: depth, childCount: object.count) + var converted: [String: YouTubeAskJSONValue] = [:] + converted.reserveCapacity(object.count) + for key in object.keys.sorted() { + guard let value = object[key] else { continue } + converted[key] = try Self.convert(value, depth: depth + 1, budget: &budget) + } + return .object(converted) + } + if let array = raw as? [Any] { + try budget.visit(depth: depth, childCount: array.count) + return try .array(array.map { value in + try Self.convert(value, depth: depth + 1, budget: &budget) + }) + } + + try budget.visit(depth: depth) + if let string = raw as? String { + return .string(string) + } + if let number = raw as? NSNumber { + if CFGetTypeID(number) == CFBooleanGetTypeID() { + return .bool(number.boolValue) + } + return .number(number.doubleValue) + } + if raw is NSNull { + return .null + } + throw YouTubeAskCoreError.malformedWireResponse + } + + private static func skipASCIIWhitespace( + in bytes: [UInt8], + index: inout Int + ) { + while index < bytes.count, self.isASCIIWhitespace(bytes[index]) { + index += 1 + } + } + + private static func trimASCIIWhitespace(_ bytes: [UInt8]) -> [UInt8] { + var start = 0 + var end = bytes.count + while start < end, Self.isASCIIWhitespace(bytes[start]) { + start += 1 + } + while end > start, Self.isASCIIWhitespace(bytes[end - 1]) { + end -= 1 + } + return Array(bytes[start ..< end]) + } + + private static func isASCIIWhitespace(_ byte: UInt8) -> Bool { + byte == 0x09 || byte == 0x0A || byte == 0x0D || byte == 0x20 + } +} diff --git a/Tests/YouTubeAskCoreTests/Fixtures/YouTubeAskConversation.json b/Tests/YouTubeAskCoreTests/Fixtures/YouTubeAskConversation.json new file mode 100644 index 000000000..af398d51c --- /dev/null +++ b/Tests/YouTubeAskCoreTests/Fixtures/YouTubeAskConversation.json @@ -0,0 +1,57 @@ +[ + { + "onResponseReceivedCommands": [ + { + "appendContinuationItemsAction": { + "continuationItems": [ + { + "youChatTextMessageViewModel": { + "text": { + "content": "First assistant message" + } + } + }, + { + "youChatTextMessageViewModel": { + "text": { + "content": "First assistant message" + } + } + }, + { + "youChatItemViewModel": { + "text": { + "runs": [ + { + "text": "Second assistant message" + } + ] + } + } + }, + { + "youChatItemViewModel": { + "chipsData": { + "chipData": [ + { + "text": { + "simpleText": "Continue with details" + }, + "continuation": "fixture-conversation-continuation-a" + }, + { + "text": { + "simpleText": "Continue with details" + }, + "continuation": "fixture-conversation-continuation-b" + } + ] + } + } + } + ] + } + } + ] + } +] diff --git a/Tests/YouTubeAskCoreTests/Fixtures/YouTubeAskEligibleNext.json b/Tests/YouTubeAskCoreTests/Fixtures/YouTubeAskEligibleNext.json new file mode 100644 index 000000000..7530af480 --- /dev/null +++ b/Tests/YouTubeAskCoreTests/Fixtures/YouTubeAskEligibleNext.json @@ -0,0 +1,47 @@ +{ + "engagementPanels": [ + { + "engagementPanelSectionListRenderer": { + "panelIdentifier": "PAyouchat", + "continuationEndpoint": { + "continuationCommand": { + "request": "CONTINUATION_REQUEST_TYPE_GET_PANEL", + "token": "fixture-panel-continuation" + } + }, + "content": { + "youChatItemViewModel": { + "chipsData": { + "chipData": [ + { + "text": { + "simpleText": "Explain this video" + }, + "continuation": "fixture-chip-continuation-a" + }, + { + "text": { + "runs": [ + { + "text": "Résumer les points clés" + } + ] + }, + "continuation": "fixture-chip-continuation-b" + } + ] + } + } + }, + "decoy": { + "sendUserQueryCommand": { + "continuationCommand": { + "request": "CONTINUATION_REQUEST_TYPE_GET_PANEL", + "token": "fixture-query-continuation" + } + } + } + } + } + ] +} diff --git a/Tests/YouTubeAskCoreTests/Fixtures/YouTubeAskIneligibleNext.json b/Tests/YouTubeAskCoreTests/Fixtures/YouTubeAskIneligibleNext.json new file mode 100644 index 000000000..0b2092ea5 --- /dev/null +++ b/Tests/YouTubeAskCoreTests/Fixtures/YouTubeAskIneligibleNext.json @@ -0,0 +1,24 @@ +{ + "engagementPanels": [ + { + "engagementPanelSectionListRenderer": { + "panelIdentifier": "PAai_companion", + "targetId": "engagement-panel-related", + "content": { + "youChatItemViewModel": { + "chipsData": { + "chipData": [ + { + "text": { + "simpleText": "Decoy suggestion" + }, + "continuation": "fixture-decoy-continuation" + } + ] + } + } + } + } + } + ] +} diff --git a/Tests/YouTubeAskCoreTests/Fixtures/YouTubeAskInitialPanel.json b/Tests/YouTubeAskCoreTests/Fixtures/YouTubeAskInitialPanel.json new file mode 100644 index 000000000..ae33cafd0 --- /dev/null +++ b/Tests/YouTubeAskCoreTests/Fixtures/YouTubeAskInitialPanel.json @@ -0,0 +1,30 @@ +{ + "onResponseReceivedCommands": [ + { + "appendContinuationItemsAction": { + "continuationItems": [ + { + "youChatItemViewModel": { + "chipsData": { + "chipData": [ + { + "text": { + "content": "Ask a follow-up" + }, + "continuation": "fixture-follow-up-continuation" + }, + { + "text": { + "content": "Ask a follow-up" + }, + "continuation": "fixture-follow-up-continuation" + } + ] + } + } + } + ] + } + } + ] +} diff --git a/Tests/YouTubeAskCoreTests/YouTubeAskFixtureSafetyTests.swift b/Tests/YouTubeAskCoreTests/YouTubeAskFixtureSafetyTests.swift new file mode 100644 index 000000000..bdfaca1bc --- /dev/null +++ b/Tests/YouTubeAskCoreTests/YouTubeAskFixtureSafetyTests.swift @@ -0,0 +1,196 @@ +import Foundation +import Testing + +@Suite("YouTubeAsk fixture and source safety") +struct YouTubeAskFixtureSafetyTests { + @Test("Every checked-in Ask fixture is placeholder-only and structurally safe") + func fixturesAreSafe() throws { + let resourceURL = try #require(Bundle.module.resourceURL) + let enumerator = try #require(FileManager.default.enumerator( + at: resourceURL, + includingPropertiesForKeys: [.fileSizeKey, .isRegularFileKey, .isSymbolicLinkKey], + options: [.skipsHiddenFiles] + )) + let fixtureURLs = enumerator.compactMap { element -> URL? in + guard let url = element as? URL, url.pathExtension == "json" else { return nil } + return url + } + #expect(!fixtureURLs.isEmpty) + + for url in fixtureURLs.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) { + let values = try url.resourceValues(forKeys: [ + .fileSizeKey, + .isRegularFileKey, + .isSymbolicLinkKey, + ]) + #expect(values.isRegularFile == true) + #expect(values.isSymbolicLink != true) + #expect((values.fileSize ?? 0) <= 256 * 1024) + + let data = try Data(contentsOf: url) + let text = try #require(String(data: data, encoding: .utf8)) + let json = try JSONSerialization.jsonObject(with: data) + let violations = Self.rawTextViolations(in: text) + + Self.jsonViolations(in: json, path: "$") + for violation in violations { + Issue.record( + "Unsafe fixture rule \(violation.rule) at \(violation.path) in \(url.lastPathComponent)" + ) + } + } + } + + @Test("Safety scanner rejects authorization material and realistic opaque values") + func scannerRejectsUnsafeSyntheticExamples() { + let syntheticOpaque = String(repeating: "Ab3_Cd4-Ef5.Gh6_", count: 4) + let sample: [String: Any] = [ + "authorization": "Bearer REDACTED", + "continuation": syntheticOpaque, + "contact": "placeholder@example.invalid", + ] + + let violations = Self.jsonViolations(in: sample, path: "$") + #expect(violations.count >= 3) + #expect(violations.contains { $0.rule == "sensitive-key" }) + #expect(violations.contains { $0.rule == "opaque-placeholder-required" }) + #expect(violations.contains { $0.rule == "email-address" }) + } + + @Test("YouTubeAskCore source imports Foundation only") + func coreImportsFoundationOnly() throws { + let repositoryRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let sourceDirectory = repositoryRoot + .appendingPathComponent("Sources", isDirectory: true) + .appendingPathComponent("YouTubeAskCore", isDirectory: true) + let files = try FileManager.default.contentsOfDirectory( + at: sourceDirectory, + includingPropertiesForKeys: nil + ).filter { $0.pathExtension == "swift" } + #expect(!files.isEmpty) + + for file in files { + let source = try String(contentsOf: file, encoding: .utf8) + let imports = source + .split(whereSeparator: \.isNewline) + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { $0.hasPrefix("import ") } + #expect(imports == ["import Foundation"]) + } + } + + private struct Violation { + let rule: String + let path: String + } + + private static let sensitiveKeys: Set = [ + "apisid", + "authorization", + "cookie", + "hsid", + "sapisid", + "setcookie", + "ssid", + ] + + private static let opaqueValueKeys: Set = [ + "accountid", + "apikey", + "channelid", + "clicktrackingparams", + "clientmessageid", + "continuation", + "conversationid", + "token", + "trackingparams", + "videoid", + "visitordata", + ] + + private static func rawTextViolations(in text: String) -> [Violation] { + var violations: [Violation] = [] + let patterns = [ + ("authorization-scheme", #"(?i)\b(?:bearer|sapisidhash|sapisid1phash|sapisid3phash)\s+\S+"#), + ("cookie-assignment", #"(?i)(?:^|[;\s])(?:__secure-)?(?:sid|sapisid|hsid|ssid)\s*="#), + ] + for (rule, pattern) in patterns + where text.range(of: pattern, options: .regularExpression) != nil + { + violations.append(Violation(rule: rule, path: "$")) + } + return violations + } + + private static func jsonViolations( + in value: Any, + path: String + ) -> [Violation] { + if let object = value as? [String: Any] { + return object.flatMap { key, nested -> [Violation] in + let canonicalKey = Self.canonical(key) + let nestedPath = "\(path).\(key)" + var violations: [Violation] = [] + if Self.sensitiveKeys.contains(canonicalKey) { + violations.append(Violation(rule: "sensitive-key", path: nestedPath)) + } + if Self.opaqueValueKeys.contains(canonicalKey), + let string = nested as? String, + !Self.isSafePlaceholder(string) + { + violations.append(Violation( + rule: "opaque-placeholder-required", + path: nestedPath + )) + } + violations.append(contentsOf: Self.jsonViolations(in: nested, path: nestedPath)) + return violations + } + } + if let array = value as? [Any] { + return array.enumerated().flatMap { index, nested in + Self.jsonViolations(in: nested, path: "\(path)[\(index)]") + } + } + guard let string = value as? String else { return [] } + + var violations: [Violation] = [] + if string.range( + of: #"(?i)\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b"#, + options: .regularExpression + ) != nil { + violations.append(Violation(rule: "email-address", path: path)) + } + if Self.containsLongOpaqueCandidate(string), !Self.isSafePlaceholder(string) { + violations.append(Violation(rule: "high-entropy-value", path: path)) + } + return violations + } + + private static func canonical(_ value: String) -> String { + value.lowercased().unicodeScalars.reduce(into: "") { result, scalar in + if (0x30 ... 0x39).contains(scalar.value) + || (0x61 ... 0x7A).contains(scalar.value) + { + result.unicodeScalars.append(scalar) + } + } + } + + private static func isSafePlaceholder(_ value: String) -> Bool { + value.range( + of: #"^(?:fixture|mock|placeholder|test)-[a-z0-9-]+$"#, + options: .regularExpression + ) != nil + || value.range(of: #"^youchat-[0-9]+$"#, options: .regularExpression) != nil + } + + private static func containsLongOpaqueCandidate(_ value: String) -> Bool { + value.range( + of: #"(? [String: Any] { + [ + "engagementPanels": [[ + "panelIdentifier": "PAyouchat", + "commands": tokens.map { token in + [ + "continuationCommand": [ + "request": "CONTINUATION_REQUEST_TYPE_GET_PANEL", + "token": token, + ], + ] + }, + ]], + ] + } + + private static func conversationEnvelope( + chips: [[String: Any]] + ) throws -> YouTubeAskWireEnvelope { + try self.conversationEnvelope(items: [[ + "youChatItemViewModel": [ + "chipsData": [ + "chipData": chips, + ], + ], + ]]) + } + + private static func conversationEnvelope( + items: [[String: Any]] + ) throws -> YouTubeAskWireEnvelope { + try self.envelope([ + "onResponseReceivedCommands": [[ + "appendContinuationItemsAction": [ + "continuationItems": items, + ], + ]], + ]) + } + + private static func envelope( + _ object: [String: Any] + ) throws -> YouTubeAskWireEnvelope { + let data = try JSONSerialization.data(withJSONObject: object) + return try YouTubeAskWireDecoder.decode(data) + } +} diff --git a/Tests/YouTubeAskCoreTests/YouTubeAskRequestBuilderTests.swift b/Tests/YouTubeAskCoreTests/YouTubeAskRequestBuilderTests.swift new file mode 100644 index 000000000..88b32a7ce --- /dev/null +++ b/Tests/YouTubeAskCoreTests/YouTubeAskRequestBuilderTests.swift @@ -0,0 +1,108 @@ +import Foundation +import Testing +@testable import YouTubeAskCore + +@Suite("YouTubeAsk direct-chip request builder") +struct YouTubeAskRequestBuilderTests { + @Test("Builds the exact panel-bootstrap body") + func exactPanelBootstrapBody() throws { + let envelope = try YouTubeAskTestFixture.envelope("YouTubeAskEligibleNext") + let parsedBootstrap = try YouTubeAskParser.parseBootstrap(from: envelope) + let bootstrap = try #require(parsedBootstrap) + let command = try #require(bootstrap.panelCommand) + + let data = YouTubeAskRequestBuilder.makePanelBootstrapBody(command: command) + let body = try YouTubeAskTestFixture.object(from: data) + + #expect(Set(body.keys) == ["continuation"]) + #expect(body["continuation"] as? String == "fixture-panel-continuation") + } + + @Test("Builds the exact direct-chip body without forbidden fields") + func exactDirectChipBody() throws { + let envelope = try YouTubeAskTestFixture.envelope("YouTubeAskEligibleNext") + let parsedBootstrap = try YouTubeAskParser.parseBootstrap(from: envelope) + let bootstrap = try #require(parsedBootstrap) + let suggestion = try #require(bootstrap.suggestions.first) + + let data = try YouTubeAskRequestBuilder.makeDirectChipBody( + command: suggestion.command, + clientMessageID: "youchat-1000" + ) + let body = try YouTubeAskTestFixture.object(from: data) + + #expect(Set(body.keys) == ["continuation", "formData"]) + #expect(body["continuation"] as? String == "fixture-chip-continuation-a") + let formData = try #require(body["formData"] as? [String: Any]) + #expect(Set(formData.keys) == ["inputComposerFormData"]) + let composer = try #require(formData["inputComposerFormData"] as? [String: Any]) + #expect(Set(composer.keys) == ["clientMessageId"]) + #expect(composer["clientMessageId"] as? String == "youchat-1000") + + let keys = Self.allKeys(in: body) + for forbidden in [ + "chipId", + "clickTrackingParams", + "conversationId", + "pendingSuggestedQueryIdentifier", + "previousClientMessageId", + "trackingParams", + "userInputText", + ] { + #expect(!keys.contains(forbidden)) + } + let bodyText = try #require(String(data: data, encoding: .utf8)) + #expect(!bodyText.contains(suggestion.label)) + } + + @Test("Rejects malformed client message IDs") + func clientMessageIDValidation() throws { + let conversation = try YouTubeAskParser.parseConversation( + from: YouTubeAskTestFixture.envelope("YouTubeAskInitialPanel") + ) + let command = try #require(conversation.suggestions.first?.command) + + for invalid in [ + "", + "fixture-message", + "youchat-", + "youchat-not-numeric", + "youchat-123", + "youchat-١٢٣", + "youchat-" + String(repeating: "1", count: 57), + ] { + expectYouTubeAskError(.invalidClientMessageID) { + _ = try YouTubeAskRequestBuilder.makeDirectChipBody( + command: command, + clientMessageID: invalid + ) + } + } + } + + @Test("Opaque commands have redacted descriptions and reflection") + func opaqueCommandRedaction() throws { + let conversation = try YouTubeAskParser.parseConversation( + from: YouTubeAskTestFixture.envelope("YouTubeAskInitialPanel") + ) + let command = try #require(conversation.suggestions.first?.command) + + #expect(String(describing: command) == "") + #expect(String(reflecting: command) == "") + #expect(!String(describing: command.customMirror.subjectType).contains("fixture")) + } + + private static func allKeys(in value: Any) -> Set { + if let object = value as? [String: Any] { + return object.reduce(into: Set(object.keys)) { result, element in + result.formUnion(Self.allKeys(in: element.value)) + } + } + if let array = value as? [Any] { + return array.reduce(into: []) { result, element in + result.formUnion(Self.allKeys(in: element)) + } + } + return [] + } +} diff --git a/Tests/YouTubeAskCoreTests/YouTubeAskRequestProfileTests.swift b/Tests/YouTubeAskCoreTests/YouTubeAskRequestProfileTests.swift new file mode 100644 index 000000000..a7414d0f9 --- /dev/null +++ b/Tests/YouTubeAskCoreTests/YouTubeAskRequestProfileTests.swift @@ -0,0 +1,33 @@ +import Testing +@testable import YouTubeAskCore + +@Suite("YouTubeAsk request profiles") +struct YouTubeAskRequestProfileTests { + @Test("Fixed production profile matches YouTubeClient without credential-bearing values") + func fixedProductionProfile() { + let profile = YouTubeAskRequestProfile.fixedProduction + + #expect(profile.clientVersion == "2.20260611.01.00") + #expect(profile.clientVersion == YouTubeAskRequestProfile.productionClientVersion) + #expect(!profile.includesRuntimeAPIParameter) + #expect(!profile.usesVisitorData) + #expect(!profile.usesAllSIDProofs) + } + + @Test("Parity profiles preserve the required validation order") + func orderedParityProfiles() { + let profiles = YouTubeAskRequestProfile.orderedParityProfiles( + runtimeClientVersion: "runtime-version-placeholder" + ) + + #expect(profiles.count == 3) + #expect(profiles[0] == .fixedProduction) + #expect(profiles[1] == .fixedProductionWithAllSIDProofs) + #expect(profiles[2] == YouTubeAskRequestProfile( + clientVersion: "runtime-version-placeholder", + includesRuntimeAPIParameter: true, + usesVisitorData: true, + usesAllSIDProofs: true + )) + } +} diff --git a/Tests/YouTubeAskCoreTests/YouTubeAskTestSupport.swift b/Tests/YouTubeAskCoreTests/YouTubeAskTestSupport.swift new file mode 100644 index 000000000..38af36a26 --- /dev/null +++ b/Tests/YouTubeAskCoreTests/YouTubeAskTestSupport.swift @@ -0,0 +1,34 @@ +import Foundation +import Testing +@testable import YouTubeAskCore + +// MARK: - YouTubeAskTestFixture + +enum YouTubeAskTestFixture { + static func data(_ name: String) throws -> Data { + let url = try #require(Bundle.module.url(forResource: name, withExtension: "json")) + return try Data(contentsOf: url) + } + + static func envelope(_ name: String) throws -> YouTubeAskWireEnvelope { + try YouTubeAskWireDecoder.decode(self.data(name)) + } + + static func object(from data: Data) throws -> [String: Any] { + try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + } +} + +func expectYouTubeAskError( + _ expected: YouTubeAskCoreError, + performing operation: () throws -> Void +) { + do { + try operation() + Issue.record("Expected YouTubeAskCoreError.\(expected)") + } catch let error as YouTubeAskCoreError { + #expect(error == expected) + } catch { + Issue.record("Expected YouTubeAskCoreError, received a different error type") + } +} diff --git a/Tests/YouTubeAskCoreTests/YouTubeAskVisibleTextSanitizerTests.swift b/Tests/YouTubeAskCoreTests/YouTubeAskVisibleTextSanitizerTests.swift new file mode 100644 index 000000000..08f1fd579 --- /dev/null +++ b/Tests/YouTubeAskCoreTests/YouTubeAskVisibleTextSanitizerTests.swift @@ -0,0 +1,91 @@ +import Foundation +import Testing +@testable import YouTubeAskCore + +@Suite("YouTubeAsk visible-text sanitizer") +struct YouTubeAskVisibleTextSanitizerTests { + @Test("Preserves localized visible text while removing controls and bidi formatting") + func preservesLocalizedText() throws { + let bidiOverride = try #require(UnicodeScalar(0x202E)) + let input = " مرحبًا \(String(bidiOverride))Résumé\u{0000}\u{0007}\n次の行\t✅ " + + let result = try #require(YouTubeAskVisibleTextSanitizer.sanitizeAnswer(input)) + + #expect(result.text == "مرحبًا Résumé\n次の行\t✅") + #expect(!result.wasTruncated) + } + + @Test("Removes CSI, OSC, and incomplete terminal sequences") + func removesTerminalSequences() throws { + let escape = try String(#require(UnicodeScalar(0x1B))) + let bell = try String(#require(UnicodeScalar(0x07))) + let input = "Start \(escape)[31mred\(escape)[0m middle " + + "\(escape)]8;;https://placeholder.invalid\(bell)linked\(escape)]8;;\(bell) " + + "end\(escape)[999" + + let result = try #require(YouTubeAskVisibleTextSanitizer.sanitizeAnswer(input)) + + #expect(result.text == "Start red middle linked end") + } + + @Test("Strips Markdown, autolink, HTTP, and www destinations") + func stripsLinks() throws { + let input = "Read [the label](https://placeholder.invalid/page), " + + ", https://placeholder.invalid/plain, " + + "and www.placeholder.invalid/path" + + let result = try #require(YouTubeAskVisibleTextSanitizer.sanitizeAnswer(input)) + + #expect(result.text.contains("the label")) + #expect(!result.text.contains("placeholder.invalid")) + #expect(result.text.components(separatedBy: "[link omitted]").count - 1 == 3) + } + + @Test("Replaces high-entropy and UUID-shaped identifiers without removing normal prose") + func stripsOpaqueIdentifiers() throws { + let opaque = String(repeating: "Ab3_Cd4-Ef5.Gh6_", count: 4) + let placeholderUUID = "00000000-0000-0000-0000-000000000000" + let ordinary = String(repeating: "lowercaseword", count: 8) + let input = "Before \(opaque) and \(placeholderUUID); keep \(ordinary)." + + let result = try #require(YouTubeAskVisibleTextSanitizer.sanitizeAnswer(input)) + + #expect(!result.text.contains(opaque)) + #expect(!result.text.contains(placeholderUUID)) + #expect(result.text.components(separatedBy: "[opaque omitted]").count - 1 == 2) + #expect(result.text.contains(ordinary)) + } + + @Test("Enforces chip-label limits without truncation") + func chipLabelLimits() throws { + let exact = String(repeating: "a", count: YouTubeAskLimits.maximumChipCharacters) + let oversized = exact + "b" + + let accepted = try #require(YouTubeAskVisibleTextSanitizer.sanitizeChipLabel(exact)) + #expect(accepted.text.count == YouTubeAskLimits.maximumChipCharacters) + #expect(!accepted.wasTruncated) + #expect(YouTubeAskVisibleTextSanitizer.sanitizeChipLabel(oversized) == nil) + } + + @Test("Truncates answers at a Character boundary and reports truncation") + func answerLimits() throws { + let exact = String(repeating: "✅", count: YouTubeAskLimits.maximumAnswerCharacters) + let oversized = exact + "✅" + + let accepted = try #require(YouTubeAskVisibleTextSanitizer.sanitizeAnswer(exact)) + #expect(accepted.text.count == YouTubeAskLimits.maximumAnswerCharacters) + #expect(!accepted.wasTruncated) + + let truncated = try #require(YouTubeAskVisibleTextSanitizer.sanitizeAnswer(oversized)) + #expect(truncated.text.count == YouTubeAskLimits.maximumAnswerCharacters) + #expect(truncated.text.last == "✅") + #expect(truncated.wasTruncated) + } + + @Test("Returns nil when sanitization leaves no visible text") + func rejectsEmptySanitizedText() throws { + let escape = try String(#require(UnicodeScalar(0x1B))) + #expect(YouTubeAskVisibleTextSanitizer.sanitizeAnswer("\u{0000}\u{0007}") == nil) + #expect(YouTubeAskVisibleTextSanitizer.sanitizeChipLabel("\(escape)[31m") == nil) + } +} diff --git a/Tests/YouTubeAskCoreTests/YouTubeAskWireDecoderTests.swift b/Tests/YouTubeAskCoreTests/YouTubeAskWireDecoderTests.swift new file mode 100644 index 000000000..d060a4948 --- /dev/null +++ b/Tests/YouTubeAskCoreTests/YouTubeAskWireDecoderTests.swift @@ -0,0 +1,253 @@ +import Foundation +import Testing +@testable import YouTubeAskCore + +@Suite("YouTubeAsk wire decoder") +struct YouTubeAskWireDecoderTests { + @Test("Decodes JSON objects and preserves JSON arrays as one root") + func decodesJSONRoots() throws { + let objectEnvelope = try YouTubeAskWireDecoder.decode(Data(#"{"value":true}"#.utf8)) + #expect(objectEnvelope.format == .jsonObject) + #expect(objectEnvelope.frameCount == 1) + #expect(objectEnvelope.roots.first?["value"] == .bool(true)) + + let arrayEnvelope = try YouTubeAskWireDecoder.decode(Data(#"[{"index":1},{"index":2}]"#.utf8)) + #expect(arrayEnvelope.format == .jsonArray) + #expect(arrayEnvelope.frameCount == 1) + guard case let .array(items) = try #require(arrayEnvelope.roots.first) else { + Issue.record("Expected one preserved JSON array root") + return + } + #expect(items.count == 2) + #expect(items[0]["index"] == .number(1)) + #expect(items[1]["index"] == .number(2)) + } + + @Test("Decodes every supported XSSI prefix after BOM and whitespace", arguments: [")]}'", "for(;;);", "while(1);"]) + func decodesXSSIPrefix(prefix: String) throws { + let bytes = Data([0xEF, 0xBB, 0xBF]) + Data(" \n\(prefix),\n{\"value\":\"safe\"}".utf8) + let envelope = try YouTubeAskWireDecoder.decode(bytes) + + #expect(envelope.hadXSSIPrefix) + #expect(envelope.format == .jsonObject) + #expect(envelope.roots.first?["value"] == .string("safe")) + } + + @Test("Decodes NDJSON with blank lines and CRLF while preserving frame order") + func decodesNDJSON() throws { + let data = Data("\r\n{\"index\":1}\r\n\r\n{\"index\":2}\n".utf8) + let envelope = try YouTubeAskWireDecoder.decode(data) + + #expect(envelope.format == .newlineDelimitedJSON) + #expect(envelope.frameCount == 2) + #expect(envelope.roots[0]["index"] == .number(1)) + #expect(envelope.roots[1]["index"] == .number(2)) + } + + @Test("Decodes byte-counted length-prefixed frames with Unicode") + func decodesLengthPrefixedFrames() throws { + let first = Data(#"{"text":"Résumé"}"#.utf8) + let second = Data(#"[{"index":2}]"#.utf8) + let data = Self.lengthPrefixed([first, second], lineEnding: "\r\n") + + let envelope = try YouTubeAskWireDecoder.decode(data) + + #expect(envelope.format == .lengthPrefixedJSON) + #expect(envelope.frameCount == 2) + #expect(envelope.roots[0]["text"] == .string("Résumé")) + guard case let .array(items) = envelope.roots[1] else { + Issue.record("Expected array frame") + return + } + #expect(items.first?["index"] == .number(2)) + } + + @Test("Incremental decoding is independent of transport chunk boundaries") + func incrementalChunkBoundaries() throws { + let data = Self.lengthPrefixed([ + Data(#"{"frame":1}"#.utf8), + Data(#"{"frame":2}"#.utf8), + ]) + + for split in 0 ... data.count { + var decoder = YouTubeAskWireDecoder() + try decoder.append(data.prefix(split)) + try decoder.append(data.suffix(data.count - split)) + let envelope = try decoder.finish() + #expect(envelope.frameCount == 2) + #expect(envelope.roots[0]["frame"] == .number(1)) + #expect(envelope.roots[1]["frame"] == .number(2)) + } + + var byteDecoder = YouTubeAskWireDecoder() + for byte in data { + try byteDecoder.append(Data([byte])) + } + #expect(try byteDecoder.finish().frameCount == 2) + } + + @Test("Enforces the total response limit during collection") + func totalResponseLimit() throws { + var exactDecoder = YouTubeAskWireDecoder() + try exactDecoder.append(Data(repeating: 0x20, count: YouTubeAskLimits.maximumResponseBytes)) + expectYouTubeAskError(.emptyResponse) { + _ = try exactDecoder.finish() + } + + var oversizedDecoder = YouTubeAskWireDecoder() + expectYouTubeAskError(.responseTooLarge) { + try oversizedDecoder.append( + Data(repeating: 0x20, count: YouTubeAskLimits.maximumResponseBytes + 1) + ) + } + } + + @Test("Enforces the per-frame limit at the exact boundary") + func frameLimit() throws { + let exactFrame = Self.jsonObject(totalByteCount: YouTubeAskLimits.maximumFrameBytes) + #expect(try YouTubeAskWireDecoder.decode(exactFrame).format == .jsonObject) + + let oversizedFrame = Self.jsonObject(totalByteCount: YouTubeAskLimits.maximumFrameBytes + 1) + expectYouTubeAskError(.frameTooLarge) { + _ = try YouTubeAskWireDecoder.decode(oversizedFrame) + } + + let oversizedLength = Data("\(YouTubeAskLimits.maximumFrameBytes + 1)\n".utf8) + expectYouTubeAskError(.frameTooLarge) { + _ = try YouTubeAskWireDecoder.decode(oversizedLength) + } + } + + @Test("Accepts 256 frames and rejects a 257th frame") + func frameCountLimit() throws { + let frames = (0 ..< YouTubeAskLimits.maximumFrames).map { index in + Data("{\"index\":\(index)}".utf8) + } + let accepted = frames.reduce(into: Data()) { result, frame in + result.append(frame) + result.append(0x0A) + } + #expect(try YouTubeAskWireDecoder.decode(accepted).frameCount == 256) + + var rejected = accepted + rejected.append(Data(#"{"index":256}"#.utf8)) + rejected.append(0x0A) + expectYouTubeAskError(.tooManyFrames) { + _ = try YouTubeAskWireDecoder.decode(rejected) + } + } + + @Test("Rejects duplicate object members before dictionary materialization") + func rejectsDuplicateJSONKeys() throws { + let duplicate = Data(#"{"token":"fixture-a","token":"fixture-b"}"#.utf8) + expectYouTubeAskError(.duplicateJSONKey) { + _ = try YouTubeAskWireDecoder.decode(duplicate) + } + + let escapedDuplicate = Data(#"{"token":1,"to\u006ben":2}"#.utf8) + expectYouTubeAskError(.duplicateJSONKey) { + _ = try YouTubeAskWireDecoder.decode(escapedDuplicate) + } + + let distinctObjects = Data(#"[{"token":"fixture-a"},{"token":"fixture-b"}]"#.utf8) + #expect(try YouTubeAskWireDecoder.decode(distinctObjects).format == .jsonArray) + } + + @Test("Malformed later frames fail the whole response") + func malformedLaterFrameFailsClosed() { + let malformedNDJSON = Data("{\"frame\":1}\n{not-json}\n".utf8) + expectYouTubeAskError(.malformedWireResponse) { + _ = try YouTubeAskWireDecoder.decode(malformedNDJSON) + } + + let incompleteLengthFrame = Data("12\n{\"short\":1}".utf8) + expectYouTubeAskError(.malformedWireResponse) { + _ = try YouTubeAskWireDecoder.decode(incompleteLengthFrame) + } + } + + @Test("Rejects scalar roots, repeated finish, and append after finish") + func rejectsUnsupportedStates() throws { + expectYouTubeAskError(.unsupportedJSONRoot) { + _ = try YouTubeAskWireDecoder.decode(Data("42".utf8)) + } + + var decoder = YouTubeAskWireDecoder() + try decoder.append(Data(#"{}"#.utf8)) + _ = try decoder.finish() + expectYouTubeAskError(.decoderAlreadyFinished) { + _ = try decoder.finish() + } + expectYouTubeAskError(.decoderAlreadyFinished) { + try decoder.append(Data()) + } + } + + @Test("Bounds decoded JSON depth and container width") + func boundsJSONStructure() throws { + let depth = YouTubeAskLimits.maximumTreeDepth + 2 + let deepJSON = Data((String(repeating: #"{"next":"#, count: depth) + #"{}"# + + String(repeating: "}", count: depth)).utf8) + expectYouTubeAskError(.structureLimitExceeded) { + _ = try YouTubeAskWireDecoder.decode(deepJSON) + } + + let wideObject = Dictionary(uniqueKeysWithValues: + (0 ... YouTubeAskLimits.maximumChildrenPerContainer).map { index in + ("field-\(index)", index) + }) + let wideData = try JSONSerialization.data(withJSONObject: wideObject) + expectYouTubeAskError(.structureLimitExceeded) { + _ = try YouTubeAskWireDecoder.decode(wideData) + } + } + + @Test("Duplicate-key validation enforces width and node budgets before materialization") + func duplicateKeyValidationBoundsStructure() { + let wideObject = "{" + (0 ... YouTubeAskLimits.maximumChildrenPerContainer) + .map { "\"field-\($0)\":0" } + .joined(separator: ",") + "}" + expectYouTubeAskError(.structureLimitExceeded) { + try YouTubeAskJSONDuplicateKeyValidator.validate(Data(wideObject.utf8)) + } + + let leafArray = "[" + Array(repeating: "0", count: 50).joined(separator: ",") + "]" + let nodeHeavyArray = "[" + Array( + repeating: leafArray, + count: YouTubeAskLimits.maximumChildrenPerContainer + ).joined(separator: ",") + "]" + expectYouTubeAskError(.structureLimitExceeded) { + try YouTubeAskJSONDuplicateKeyValidator.validate(Data(nodeHeavyArray.utf8)) + } + } + + @Test("Duplicate-key validation classifies truncated values as malformed") + func duplicateKeyValidationRejectsTruncatedValues() { + expectYouTubeAskError(.malformedWireResponse) { + try YouTubeAskJSONDuplicateKeyValidator.validate(Data(#"{"value":"#.utf8)) + } + } + + private static func lengthPrefixed( + _ frames: [Data], + lineEnding: String = "\n" + ) -> Data { + frames.reduce(into: Data()) { result, frame in + result.append(Data("\(frame.count)\(lineEnding)".utf8)) + result.append(frame) + } + } + + private static func jsonObject(totalByteCount: Int) -> Data { + let prefix = Data("{\"value\":\"".utf8) + let suffix = Data("\"}".utf8) + precondition(totalByteCount >= prefix.count + suffix.count) + var data = prefix + data.append(Data( + repeating: 0x61, + count: totalByteCount - prefix.count - suffix.count + )) + data.append(suffix) + return data + } +} diff --git a/docs/api-discovery.md b/docs/api-discovery.md index 2c408b25c..258fe4bce 100644 --- a/docs/api-discovery.md +++ b/docs/api-discovery.md @@ -140,7 +140,7 @@ swift run api-explorer brandaccounts 0: Primary Account (@handle) [Primary] ← current 1: Brand Channel (@brand-handle) [Brand Account] - Brand ID: 111997145576882617490 + Brand ID: ``` **API Response Path**: @@ -166,7 +166,7 @@ let body: [String: Any] = [ "clientVersion": "1.20231204.01.00" ], "user": [ - "onBehalfOfUser": "111997145576882617490" // Brand account ID + "onBehalfOfUser": "" // Brand account ID ] ], "browseId": "FEmusic_liked_playlists" @@ -179,7 +179,7 @@ let body: [String: Any] = [ swift run api-explorer brandaccounts # Access brand account library -swift run api-explorer browse FEmusic_liked_playlists --brand 111997145576882617490 +swift run api-explorer browse FEmusic_liked_playlists --brand ``` #### Key Differences: authuser vs brand @@ -1383,6 +1383,131 @@ Field notes: Prefer the destination feeds documented in [youtube.md](youtube.md) for Explore; YouTube's old `FEtrending` feed is no longer a reliable target. +#### YouTube Ask Gemini / YouChat investigation (2026-07-27) + +YouTube's **Ask Gemini** watch-page experience is an undocumented, internal +YouChat engagement-panel surface. It is not a public API, and its availability, +request schemas, and frontend identifiers are subject to account eligibility, +server rollout, client version, and video-specific changes. + +Start with the redacted read-only audit. Use the separate live command only when +an explicit request to contact the live AI service has been approved: + +```bash +# Read-only: audits watch responses and frontend capability markers +swift run api-explorer ask-video-audit + +# Read-only: compares the ordered production/request-compatibility profiles +swift run api-explorer ask-video-parity + +# Live: replays only the server-issued summary suggestion +swift run api-explorer ask-video-live-test --confirm-live-ai + +# Live: summary plus the first server-issued follow-up suggestion +swift run api-explorer ask-video-live-test --confirm-live-ai --follow-up + +# Live: two independent watch/panel bootstraps, capped at three +swift run api-explorer ask-video-live-test --confirm-live-ai --fresh-chats 2 + +# Manual structural probe for object, array, streaming, or opaque responses +swift run api-explorer --youtube wire-action '' +``` + +`ask-video-audit` redacts opaque values, does not save raw payloads, and never +submits a query. `ask-video-live-test` requires `--confirm-live-ai`, keeps all +opaque continuations and message state in memory, rejects raw output files, and +accepts no arbitrary prompt text. Its generated answer display strips control +and bidirectional formatting characters, hides links and high-entropy opaque +strings, and is bounded to 16,000 characters per answer. + +**Read-only production-parity matrix (added July 28, 2026):** + +`ask-video-parity` tests the credential-free profiles defined by +`YouTubeAskRequestProfile` in this order: + +1. Fixed production client version, no API key, no visitor data, and one SID proof. +2. The same fixed production configuration with all available SID proof schemes. +3. The runtime WEB client-version/API-key/visitor-data bundle with all available + SID proof schemes. + +For each profile, the command makes an authenticated `next` request and, only +when strict parsing finds one unambiguous panel bootstrap, materializes the +initial `get_panel`. It never submits a suggestion chip, free text, or any other +generation request. Both responses use the bounded `YouTubeAskCore` wire decoder +and strict parser. Terminal output is limited to the profile name, HTTP status, +response size, wire format, eligibility, chip counts, and a redacted failure +category. The command stops at the first passing profile and rejects raw-output, +private-body, client-version override, follow-up, and multi-chat options. + +The read-only run on **July 28, 2026** completed all three profiles. Every +`next` request returned HTTP 200, but each response reported the exported +session as signed out, so `get_panel` was not run and no profile passed. This is +an authentication rejection, not evidence that any request profile is valid or +invalid for an eligible signed-in session. Production therefore remains +disabled and fail-closed; a future run must confirm signed-in primary-account +eligibility before selecting a profile. + +`get_panel`, `streaming_panel`, and `get_answer` must use `wire-action` for manual +probes; the raw `action` command rejects them. Supply manual panel JSON through +`--body-file` using a mode-0600 regular file, or use `--body-file -` to read +stdin, so opaque values do not appear in argv or normal shell history. Endpoint +arguments must be plain relative API paths. + +**Observed frontend identifiers**: + +- `PAyouchat` +- `engagement-panel-youchat` +- `PAai_companion` + +**Observed transport behavior**: + +| Transport | Current interpretation | +|-----------|------------------------| +| `get_panel` | Panel bootstrap and direct suggestion-chip continuation transport | +| `streaming_panel` | Free-text streaming transport only when a server-issued command explicitly selects this API path; observed responses use a top-level JSON array | +| `get_watch` | Combined player/watch bootstrap; observed responses use a top-level JSON array | +| `get_answer` | Separate AI answer transport; not used by the verified watch-page suggestion flow | + +A direct suggestion chip does **not** submit its visible text. The current +frontend creates a `CONTINUATION_REQUEST_TYPE_GET_PANEL` command from the exact +server-issued `chipData.continuation` and posts it to `get_panel` with: + +```text +continuation: exact server-issued chip continuation +formData.inputComposerFormData.clientMessageId: youchat- +``` + +The browser also supplies optional playback/page/previous-message timing context +when available. API Explorer omits unavailable optional fields rather than +inventing them. The chip's `id`, visible text, click-tracking command, and the +free-text composer's `sendUserQueryCommand` are not copied into this direct-chip +request. + +The free-text composer is a different path. It uses the server-issued +`sendUserQueryCommand` (or its own fallback continuation), adds `userInputText`, +and selects `streaming_panel` only when command metadata explicitly names that +endpoint. Arbitrary free-text submission is intentionally not implemented by +`ask-video-live-test`. + +**Live validation on July 27, 2026**: + +- The refreshed cookie export was accepted as a signed-in YouTube WEB session. +- The watch bootstrap exposed `PAyouchat`, a summary chip, and a fresh panel + continuation. +- Replaying the summary chip through `get_panel` returned HTTP 200 and a generated + summary. +- Replaying the first follow-up chip from that response returned HTTP 200 and a + generated follow-up answer in the same flow. +- Two independent watch/panel bootstraps both returned HTTP 200 summaries; the + generated text was not exactly identical. +- Sending the chip continuation to `streaming_panel` without form data returned + HTTP 400, confirming that it is not the direct-chip transport. + +These results validate the current eligible-account flow, not a stable contract. +Treat the surface as rollout-fragile. Never commit or display cookies, +authorization material, account identifiers, conversation identifiers, visitor +or session values, opaque params, continuations, or server-issued commands. + ### Authenticated Endpoints For authenticated endpoints (🔐), sign in to the Kaset app first: @@ -1406,7 +1531,7 @@ Debug builds export auth cookies for the API explorer to `~/Library/Application swift run api-explorer brandaccounts # Access a brand account's library -swift run api-explorer browse FEmusic_liked_playlists --brand 111997145576882617490 +swift run api-explorer browse FEmusic_liked_playlists --brand ``` The `--brand` flag sets `context.user.onBehalfOfUser` in the request body. See [Brand Account Support](#brand-account-support) in the Authentication section for details. @@ -1416,7 +1541,11 @@ The `--brand` flag sets `context.user.onBehalfOfUser` in the request body. See [ | Command | Description | |---------|-------------| | `browse [params]` | Explore a browse endpoint | -| `action ` | Explore an action endpoint | +| `action ` | Explore an action endpoint that returns a top-level JSON object | +| `wire-action ` | Safely inspect object, array, streaming, or opaque wire responses without printing raw values | +| `ask-video-audit ` | Run a redacted, read-only Ask Gemini / YouChat audit without sending a prompt | +| `ask-video-parity ` | Test ordered read-only Ask request profiles using only `next` and initial `get_panel`; never submits a chip | +| `ask-video-live-test ` | With `--confirm-live-ai`, replay the server-issued summary chip; optionally add `--follow-up` or `--fresh-chats N` | | `search-audit ` | Audit live Music search shapes, filter chips, continuations, and parser coverage | | `continuation [ep]` | Explore a continuation (`browse`, `search`, or `next`); use the same auth mode as the originating request (`--guest` for guest search) | | `list` | List all known endpoints | @@ -1429,11 +1558,15 @@ The `--brand` flag sets `context.user.onBehalfOfUser` in the request body. See [ | Option | Description | |--------|-------------| -| `-v, --verbose` | Show full raw JSON for browse/action/continuation commands; expand samples and filter params for `search-audit` | -| `-o, --output ` | Save raw JSON to file | +| `-v, --verbose` | Show full raw JSON for browse/action/continuation commands; expand audit and search samples | +| `-o, --output ` | Save raw output with owner-only permissions; `ask-video-audit` ignores this option | | `--authuser N` | Use Google account at index N | | `--brand ` | Use brand account (21-digit ID) | | `--client-version ` | Override the resolved InnerTube client version for compatibility probes | +| `--body-file ` | Read a sensitive JSON action body from a mode-0600 regular file or stdin; required for panel/answer transports | +| `--confirm-live-ai` | Required explicit acknowledgement before `ask-video-live-test` sends live AI requests | +| `--follow-up` | Replay the first follow-up chip returned by the live summary response | +| `--fresh-chats N` | Run 1-3 independent summary bootstraps (default: 1) | | `--youtube`, `--yt` | Target regular YouTube (`www.youtube.com`, WEB client) instead of YouTube Music | --- @@ -1453,6 +1586,8 @@ The `--brand` flag sets `context.user.onBehalfOfUser` in the request body. See [ | Date | Changes | |------|---------| +| 2026-07-28 | Added redacted read-only `ask-video-parity` tooling backed by `YouTubeAskCore`; all three profiles returned HTTP 200 `next` responses but the exported session was treated as signed out, so no profile passed and production remains disabled | +| 2026-07-27 | Live-validated YouTube Ask Gemini / YouChat summary, follow-up, and two fresh chats; added guarded `ask-video-live-test`, corrected direct chips to `get_panel`, retained read-only `ask-video-audit`, and documented redaction/auth constraints | | 2026-07-19 | Revalidated Music search: `itemSectionRenderer` mixed rows, watch-endpoint Top Results, audiobooks, videos/profiles/episodes filters, shelf and action-envelope continuations, and `/search` routing; added `search-audit` | | 2026-06-24 | Documented regular YouTube `--youtube` API Explorer mode alongside YouTube Music | | 2026-01-16 | Added comprehensive Podcast ID Format section: MPSPP→PL conversion, L-prefix validation, double-L bug documentation | From 3afba6f31425d41afa42eeaa781b99361942818e Mon Sep 17 00:00:00 2001 From: Sertac Ozercan Date: Wed, 29 Jul 2026 07:36:55 -0700 Subject: [PATCH 03/18] feat(youtube): add Ask API and domain support Signed-off-by: Sertac Ozercan --- CONTEXT.md | 24 + Sources/Kaset/KasetApp.swift | 11 + .../Models/YouTube/YouTubeAskModels.swift | 447 ++++++++++ .../Models/YouTube/YouTubeWatchPage.swift | 8 + .../API/MockUITestYouTubeClient.swift | 42 +- .../API/YouTubeAskMessageIDGenerator.swift | 20 + .../Services/API/YouTubeAskTransport.swift | 276 +++++++ .../Services/API/YouTubeClient+Ask.swift | 147 ++++ .../Kaset/Services/API/YouTubeClient.swift | 232 +++++- Sources/Kaset/Services/YouTubeProtocols.swift | 16 + Sources/Kaset/Utilities/UITestConfig.swift | 3 + .../Helpers/MockYouTubeClient.swift | 91 +++ Tests/KasetTests/YouTubeAskClientTests.swift | 761 ++++++++++++++++++ .../KasetTests/YouTubeAskTransportTests.swift | 360 +++++++++ .../YouTubeSingleFlightViewModelTests.swift | 20 + docs/adr/0032-youtube-ask-gemini.md | 90 +++ docs/adr/README.md | 1 + 17 files changed, 2543 insertions(+), 6 deletions(-) create mode 100644 Sources/Kaset/Models/YouTube/YouTubeAskModels.swift create mode 100644 Sources/Kaset/Models/YouTube/YouTubeWatchPage.swift create mode 100644 Sources/Kaset/Services/API/YouTubeAskMessageIDGenerator.swift create mode 100644 Sources/Kaset/Services/API/YouTubeAskTransport.swift create mode 100644 Sources/Kaset/Services/API/YouTubeClient+Ask.swift create mode 100644 Tests/KasetTests/YouTubeAskClientTests.swift create mode 100644 Tests/KasetTests/YouTubeAskTransportTests.swift create mode 100644 docs/adr/0032-youtube-ask-gemini.md diff --git a/CONTEXT.md b/CONTEXT.md index 1c14928ef..7b83063b9 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -51,3 +51,27 @@ The two identifiers required for album Library behavior. An `MPRE...` browse ID ## Playlist Playback Actions The workflows that turn playlist browse data into native playback queues. This includes radio playlist queue fallback, browse playability correction, playlist artwork fallback, continuation loading, duplicate filtering, and discarding continuations when the active queue has changed. + +## YouTube Ask + +The watch-page Ask Gemini capability in the regular YouTube experience. Kaset's first version is limited to server-issued suggestion chips and follow-up chips. It uses YouTube APIs rather than the playback WebView, and it remains disabled unless a read-only request-profile check proves an eligible signed-in primary-account flow. + +## Ask Bootstrap + +The eligible YouChat material discovered in the current video's watch-page `next` response. A bootstrap may expose suggestions directly or carry the opaque command needed to prepare the initial Ask panel. It is valid only for the video, authentication generation, and primary-account scope that produced it. + +## Ask Conversation + +The memory-only visible transcript and server-issued follow-up suggestions for one watch-scoped Ask chat. New Chat replaces it only after a fresh bootstrap and panel preparation succeed. Navigation, account/authentication changes, cancellation, or app termination discard it. + +## Server-Issued Ask Suggestion + +A sanitized visible chip label paired internally with an opaque server command. The label is displayed verbatim and is not a localized Kaset string. Selecting the suggestion replays its exact command; Kaset never turns the label into free-form input. + +## Opaque Ask Command + +Unprintable, non-persistent server state used only to continue the current Ask conversation. It must never be logged, cached, serialized, restored, placed in fixtures, exposed through accessibility identifiers, or reused across a video, account, conversation, or revision boundary. + +## Ask Request Profile + +The bounded YouTube WEB client configuration used only for Ask compatibility. A profile is selectable for production only after the read-only parity workflow confirms signed-in eligibility and initial panel preparation. An HTTP 200 response that contains signed-out state is a failed profile. diff --git a/Sources/Kaset/KasetApp.swift b/Sources/Kaset/KasetApp.swift index 04c5e89bd..74596b908 100644 --- a/Sources/Kaset/KasetApp.swift +++ b/Sources/Kaset/KasetApp.swift @@ -149,6 +149,17 @@ struct KasetApp: App { realYouTubeClient.accountCacheIdentityProvider = { [weak account] in account?.currentAccount?.cacheIdentity } + realYouTubeClient.askAccountBindingProvider = { [weak account] in + guard let account, + let currentAccount = account.currentAccount, + currentAccount.isPrimary, + account.verifiedAccountId == currentAccount.id, + let scopeID = account.currentAccountScopeID + else { + return nil + } + return YouTubeAskAccountBinding(scopeID: scopeID) + } let youtubeClient: YouTubeClientProtocol = if UITestConfig.isUITestMode { MockUITestYouTubeClient() } else { diff --git a/Sources/Kaset/Models/YouTube/YouTubeAskModels.swift b/Sources/Kaset/Models/YouTube/YouTubeAskModels.swift new file mode 100644 index 000000000..c75751a7c --- /dev/null +++ b/Sources/Kaset/Models/YouTube/YouTubeAskModels.swift @@ -0,0 +1,447 @@ +import Foundation +import YouTubeAskCore + +// MARK: - YouTubeAskAccountBinding + +/// Opaque, in-memory identity for the confirmed primary account that owns one +/// Ask conversation. The scope is never persisted, logged, or shown to users. +struct YouTubeAskAccountBinding: Equatable, Sendable { + fileprivate let scopeID: String + + init(scopeID: String) { + self.scopeID = scopeID + } +} + +// MARK: CustomStringConvertible, CustomDebugStringConvertible, CustomReflectable + +extension YouTubeAskAccountBinding: CustomStringConvertible, CustomDebugStringConvertible, CustomReflectable { + var description: String { + "" + } + + var debugDescription: String { + self.description + } + + var customMirror: Mirror { + Mirror(reflecting: self.description) + } +} + +// MARK: - YouTubeAskSuggestion + +struct YouTubeAskSuggestion: Identifiable, Sendable { + // swiftlint:disable:next type_name + struct ID: Hashable, Sendable { + fileprivate let rawValue: UUID + + init(rawValue: UUID = UUID()) { + self.rawValue = rawValue + } + + var accessibilityIdentifierComponent: String { + self.rawValue.uuidString + } + } + + let id: ID + let text: String +} + +// MARK: - YouTubeAskMessage + +struct YouTubeAskMessage: Identifiable, Sendable { + enum Role: Sendable { + case user + case assistant + } + + let id: UUID + let role: Role + let text: String + + init(id: UUID = UUID(), role: Role, text: String) { + self.id = id + self.role = role + self.text = text + } +} + +// MARK: - YouTubeAskBindingState + +private struct YouTubeAskBindingState: Sendable { + let videoID: String + let authenticationGeneration: UInt64 + let accountBinding: YouTubeAskAccountBinding + let clientGeneration: UInt64 + let conversationID: UUID + let revision: UInt64 + + func advanced() -> YouTubeAskBindingState { + YouTubeAskBindingState( + videoID: self.videoID, + authenticationGeneration: self.authenticationGeneration, + accountBinding: self.accountBinding, + clientGeneration: self.clientGeneration, + conversationID: self.conversationID, + revision: self.revision &+ 1 + ) + } +} + +// MARK: - YouTubeAskSuggestionState + +private struct YouTubeAskSuggestionState: Sendable { + let visible: YouTubeAskSuggestion + let command: YouTubeAskOpaqueCommand +} + +// MARK: - YouTubeAskBootstrap + +struct YouTubeAskBootstrap: Sendable { + let videoID: String + let suggestions: [YouTubeAskSuggestion] + + private let panelCommand: YouTubeAskOpaqueCommand? + private let suggestionStates: [YouTubeAskSuggestionState] + private let binding: YouTubeAskBindingState + + var requiresPanelMaterialization: Bool { + self.suggestions.isEmpty && self.panelCommand != nil + } + + fileprivate init( + videoID: String, + parsed: YouTubeAskParsedBootstrap, + authenticationGeneration: UInt64, + accountBinding: YouTubeAskAccountBinding, + clientGeneration: UInt64, + conversationID: UUID = UUID() + ) { + let suggestionStates = parsed.suggestions.map { parsedSuggestion in + YouTubeAskSuggestionState( + visible: YouTubeAskSuggestion(text: parsedSuggestion.label), + command: parsedSuggestion.command + ) + } + self.videoID = videoID + self.suggestions = suggestionStates.map(\.visible) + self.panelCommand = parsed.panelCommand + self.suggestionStates = suggestionStates + self.binding = YouTubeAskBindingState( + videoID: videoID, + authenticationGeneration: authenticationGeneration, + accountBinding: accountBinding, + clientGeneration: clientGeneration, + conversationID: conversationID, + revision: 0 + ) + } + + fileprivate func makeDirectConversation() -> YouTubeAskConversation { + YouTubeAskConversation( + messages: [], + suggestionStates: self.suggestionStates, + binding: self.binding + ) + } + + var materializationCommand: YouTubeAskOpaqueCommand? { + self.panelCommand + } + + var conversationID: UUID { + self.binding.conversationID + } + + func isBound( + toVideoID videoID: String, + authenticationGeneration: UInt64, + accountBinding: YouTubeAskAccountBinding, + clientGeneration: UInt64 + ) -> Bool { + self.binding.videoID == videoID + && self.binding.authenticationGeneration == authenticationGeneration + && self.binding.accountBinding == accountBinding + && self.binding.clientGeneration == clientGeneration + && self.binding.revision == 0 + } + + fileprivate var bindingState: YouTubeAskBindingState { + self.binding + } + + static func testing(suggestions: [String]) -> YouTubeAskBootstrap { + YouTubeAskBootstrap( + videoID: "fixture-video", + suggestions: suggestions + ) + } + + private init(videoID: String, suggestions: [String]) { + self.videoID = videoID + self.suggestions = suggestions.map { YouTubeAskSuggestion(text: $0) } + self.panelCommand = nil + self.suggestionStates = [] + self.binding = YouTubeAskBindingState( + videoID: videoID, + authenticationGeneration: 0, + accountBinding: YouTubeAskAccountBinding(scopeID: "fixture-scope"), + clientGeneration: 0, + conversationID: UUID(), + revision: 0 + ) + } +} + +// MARK: - YouTubeAskConversation + +struct YouTubeAskConversation: Sendable { + let id: UUID + let revision: UInt64 + let messages: [YouTubeAskMessage] + let suggestions: [YouTubeAskSuggestion] + + private let suggestionStates: [YouTubeAskSuggestionState] + private let binding: YouTubeAskBindingState? + private let pendingSuggestionID: YouTubeAskSuggestion.ID? + + var hasStarted: Bool { + self.messages.contains { $0.role == .user } + } + + fileprivate init( + messages: [YouTubeAskMessage], + suggestionStates: [YouTubeAskSuggestionState], + binding: YouTubeAskBindingState + ) { + self.id = binding.conversationID + self.revision = binding.revision + self.messages = messages + self.suggestions = suggestionStates.map(\.visible) + self.suggestionStates = suggestionStates + self.binding = binding + self.pendingSuggestionID = nil + } + + fileprivate init( + previousMessages: [YouTubeAskMessage], + parsed: YouTubeAskParsedConversation, + binding: YouTubeAskBindingState + ) { + let suggestionStates = parsed.suggestions.map { parsedSuggestion in + YouTubeAskSuggestionState( + visible: YouTubeAskSuggestion(text: parsedSuggestion.label), + command: parsedSuggestion.command + ) + } + let assistantMessages = parsed.messages.map { parsedMessage in + YouTubeAskMessage(role: .assistant, text: parsedMessage.text) + } + self.init( + messages: previousMessages + assistantMessages, + suggestionStates: suggestionStates, + binding: binding + ) + } + + func appendingUserTurn(for suggestionID: YouTubeAskSuggestion.ID) -> YouTubeAskConversation? { + guard self.pendingSuggestionID == nil, + let selected = self.suggestions.first(where: { $0.id == suggestionID }) + else { + return nil + } + return YouTubeAskConversation( + id: self.id, + revision: self.revision, + messages: self.messages + [YouTubeAskMessage(role: .user, text: selected.text)], + suggestions: self.suggestions, + suggestionStates: self.suggestionStates, + binding: self.binding, + pendingSuggestionID: suggestionID + ) + } + + func command(for suggestionID: YouTubeAskSuggestion.ID) -> YouTubeAskOpaqueCommand? { + guard self.pendingSuggestionID == suggestionID else { return nil } + return self.suggestionStates.first(where: { $0.visible.id == suggestionID })?.command + } + + fileprivate var bindingState: YouTubeAskBindingState? { + self.binding + } + + var boundVideoID: String? { + self.binding?.videoID + } + + func isBound( + toVideoID videoID: String, + authenticationGeneration: UInt64, + accountBinding: YouTubeAskAccountBinding, + clientGeneration: UInt64 + ) -> Bool { + guard let binding = self.binding else { return false } + return binding.videoID == videoID + && binding.authenticationGeneration == authenticationGeneration + && binding.accountBinding == accountBinding + && binding.clientGeneration == clientGeneration + && binding.conversationID == self.id + && binding.revision == self.revision + } + + func discardingOpaqueState() -> YouTubeAskConversation { + YouTubeAskConversation( + id: self.id, + revision: self.revision, + messages: self.messages, + suggestions: [], + suggestionStates: [], + binding: nil, + pendingSuggestionID: nil + ) + } + + private init( + id: UUID, + revision: UInt64, + messages: [YouTubeAskMessage], + suggestions: [YouTubeAskSuggestion], + suggestionStates: [YouTubeAskSuggestionState], + binding: YouTubeAskBindingState?, + pendingSuggestionID: YouTubeAskSuggestion.ID? + ) { + self.id = id + self.revision = revision + self.messages = messages + self.suggestions = suggestions + self.suggestionStates = suggestionStates + self.binding = binding + self.pendingSuggestionID = pendingSuggestionID + } + + static func testing( + messages: [YouTubeAskMessage] = [], + suggestions: [String] = [] + ) -> YouTubeAskConversation { + YouTubeAskConversation( + id: UUID(), + revision: messages.isEmpty ? 0 : 1, + messages: messages, + suggestions: suggestions.map { YouTubeAskSuggestion(text: $0) }, + suggestionStates: [], + binding: nil, + pendingSuggestionID: nil + ) + } +} + +// MARK: - YouTubeAskBootstrap + CustomStringConvertible, CustomDebugStringConvertible, CustomReflectable + +extension YouTubeAskBootstrap: CustomStringConvertible, CustomDebugStringConvertible, CustomReflectable { + var description: String { + "" + } + + var debugDescription: String { + self.description + } + + var customMirror: Mirror { + Mirror(reflecting: self.description) + } +} + +// MARK: - YouTubeAskConversation + CustomStringConvertible, CustomDebugStringConvertible, CustomReflectable + +extension YouTubeAskConversation: CustomStringConvertible, CustomDebugStringConvertible, CustomReflectable { + var description: String { + "" + } + + var debugDescription: String { + self.description + } + + var customMirror: Mirror { + Mirror(reflecting: self.description) + } +} + +// MARK: - YouTubeAskSuggestion Convenience + +private extension YouTubeAskSuggestion { + init(text: String) { + self.init(id: ID(), text: text) + } +} + +// MARK: - YouTubeAskClientError + +enum YouTubeAskClientError: Error, Equatable, Sendable { + case authenticationRequired + case sessionChanged + case rateLimited + case responseTooLarge + case invalidResponse + case unavailable +} + +// MARK: - YouTubeAskPresentationError + +enum YouTubeAskPresentationError: Equatable, Sendable { + case authentication + case rateLimited + case preparation + case restartRequired +} + +// MARK: - YouTubeAsk Production Support + +extension YouTubeAskBootstrap { + static func production( + videoID: String, + parsed: YouTubeAskParsedBootstrap, + authenticationGeneration: UInt64, + accountBinding: YouTubeAskAccountBinding, + clientGeneration: UInt64 + ) -> YouTubeAskBootstrap { + YouTubeAskBootstrap( + videoID: videoID, + parsed: parsed, + authenticationGeneration: authenticationGeneration, + accountBinding: accountBinding, + clientGeneration: clientGeneration + ) + } +} + +extension YouTubeAskConversation { + static func materialized( + from bootstrap: YouTubeAskBootstrap, + parsed: YouTubeAskParsedConversation + ) -> YouTubeAskConversation { + YouTubeAskConversation( + previousMessages: [], + parsed: parsed, + binding: bootstrap.bindingState + ) + } + + static func continued( + from conversation: YouTubeAskConversation, + parsed: YouTubeAskParsedConversation + ) -> YouTubeAskConversation? { + guard let binding = conversation.bindingState else { return nil } + return YouTubeAskConversation( + previousMessages: conversation.messages, + parsed: parsed, + binding: binding.advanced() + ) + } + + static func direct(from bootstrap: YouTubeAskBootstrap) -> YouTubeAskConversation { + bootstrap.makeDirectConversation() + } +} diff --git a/Sources/Kaset/Models/YouTube/YouTubeWatchPage.swift b/Sources/Kaset/Models/YouTube/YouTubeWatchPage.swift new file mode 100644 index 000000000..474de1d03 --- /dev/null +++ b/Sources/Kaset/Models/YouTube/YouTubeWatchPage.swift @@ -0,0 +1,8 @@ +import Foundation + +/// One watch-page response parsed into both the existing companion data and an +/// optional, watch-scoped Ask Gemini bootstrap from the same `next` request. +struct YouTubeWatchPage { + let data: WatchNextData + let askBootstrap: YouTubeAskBootstrap? +} diff --git a/Sources/Kaset/Services/API/MockUITestYouTubeClient.swift b/Sources/Kaset/Services/API/MockUITestYouTubeClient.swift index c5b1aa785..59da9587c 100644 --- a/Sources/Kaset/Services/API/MockUITestYouTubeClient.swift +++ b/Sources/Kaset/Services/API/MockUITestYouTubeClient.swift @@ -4,11 +4,16 @@ import Foundation /// Returns deterministic fixture data so UI tests never hit the network. @MainActor final class MockUITestYouTubeClient: YouTubeClientProtocol { + private var isAskGeminiEligible = + UITestConfig.environmentValue(for: UITestConfig.mockAskGeminiEnabledKey) == "true" + var hasMoreHomeFeed: Bool { false } - func resetSessionStateForAccountSwitch() {} + func resetSessionStateForAccountSwitch() { + self.isAskGeminiEligible = false + } func getHomeFeed() async throws -> YouTubeFeed { YouTubeFeed(videos: Self.sampleVideos, continuation: nil) @@ -99,6 +104,41 @@ final class MockUITestYouTubeClient: YouTubeClientProtocol { ) } + func getWatchPage(videoId: String) async throws -> YouTubeWatchPage { + try await YouTubeWatchPage( + data: self.getWatchNext(videoId: videoId), + askBootstrap: self.isAskGeminiEligible + ? YouTubeAskBootstrap.testing(suggestions: [ + "Explain the main idea", + "List the key moments", + ]) + : nil + ) + } + + func loadAskConversation( + from bootstrap: YouTubeAskBootstrap + ) async throws -> YouTubeAskConversation { + YouTubeAskConversation.testing( + suggestions: bootstrap.suggestions.map(\.text) + ) + } + + func continueAskConversation( + _ conversation: YouTubeAskConversation, + selecting _: YouTubeAskSuggestion.ID + ) async throws -> YouTubeAskConversation { + YouTubeAskConversation.testing( + messages: conversation.messages + [ + YouTubeAskMessage( + role: .assistant, + text: "This is a synthetic YouTube-generated response for UI tests." + ), + ], + suggestions: ["Show another detail"] + ) + } + func getComments(continuation _: String) async throws -> YouTubeCommentsPage { YouTubeCommentsPage( comments: [ diff --git a/Sources/Kaset/Services/API/YouTubeAskMessageIDGenerator.swift b/Sources/Kaset/Services/API/YouTubeAskMessageIDGenerator.swift new file mode 100644 index 000000000..50d739b79 --- /dev/null +++ b/Sources/Kaset/Services/API/YouTubeAskMessageIDGenerator.swift @@ -0,0 +1,20 @@ +import Foundation + +@MainActor +final class YouTubeAskMessageIDGenerator { + private let nowMilliseconds: () -> Int64 + private var lastMilliseconds: Int64 = 0 + + init(nowMilliseconds: @escaping () -> Int64 = { + Int64(Date().timeIntervalSince1970 * 1000) + }) { + self.nowMilliseconds = nowMilliseconds + } + + func next() -> String { + let current = self.nowMilliseconds() + let next = max(current, self.lastMilliseconds &+ 1) + self.lastMilliseconds = next + return "youchat-\(next)" + } +} diff --git a/Sources/Kaset/Services/API/YouTubeAskTransport.swift b/Sources/Kaset/Services/API/YouTubeAskTransport.swift new file mode 100644 index 000000000..adf0f6dc5 --- /dev/null +++ b/Sources/Kaset/Services/API/YouTubeAskTransport.swift @@ -0,0 +1,276 @@ +import Foundation +import YouTubeAskCore + +// MARK: - YouTubeAskHTTPResponse + +struct YouTubeAskHTTPResponse: Sendable { + let data: Data + let statusCode: Int +} + +// MARK: CustomStringConvertible, CustomDebugStringConvertible, CustomReflectable + +extension YouTubeAskHTTPResponse: CustomStringConvertible, CustomDebugStringConvertible, CustomReflectable { + var description: String { + "" + } + + var debugDescription: String { + self.description + } + + var customMirror: Mirror { + Mirror(reflecting: self.description) + } +} + +// MARK: - YouTubeAskTransportError + +enum YouTubeAskTransportError: Error, Sendable { + case responseTooLarge + case invalidResponse +} + +// MARK: - YouTubeAskTransport + +final class YouTubeAskTransport: @unchecked Sendable { + private let configuration: URLSessionConfiguration + + init(configuration: URLSessionConfiguration) { + configuration.urlCache = nil + configuration.requestCachePolicy = .reloadIgnoringLocalCacheData + configuration.httpCookieStorage = nil + configuration.httpShouldSetCookies = false + self.configuration = configuration + } + + func send(_ request: URLRequest) async throws -> YouTubeAskHTTPResponse { + guard let originURL = request.url else { + throw YouTubeAskTransportError.invalidResponse + } + let loader = YouTubeAskBoundedResponseLoader( + originURL: originURL, + maximumBytes: YouTubeAskLimits.maximumResponseBytes + ) + let (data, response) = try await loader.load( + configuration: self.configuration, + request: request + ) + guard let httpResponse = response as? HTTPURLResponse else { + throw YouTubeAskTransportError.invalidResponse + } + return YouTubeAskHTTPResponse( + data: data, + statusCode: httpResponse.statusCode + ) + } +} + +// MARK: - YouTubeAskBoundedResponseLoader + +private final class YouTubeAskBoundedResponseLoader: NSObject, URLSessionDataDelegate, @unchecked Sendable { + private let scheme: String? + private let host: String? + private let port: Int? + private let maximumBytes: Int + private let lock = NSLock() + + private var continuation: CheckedContinuation<(Data, URLResponse), any Error>? + private var session: URLSession? + private var response: URLResponse? + private var responseData = Data() + private var isFinished = false + private var cancellationRequested = false + + init(originURL: URL, maximumBytes: Int) { + self.scheme = originURL.scheme?.lowercased() + self.host = originURL.host?.lowercased() + self.port = Self.effectivePort(for: originURL) + self.maximumBytes = maximumBytes + } + + func load( + configuration: URLSessionConfiguration, + request: URLRequest + ) async throws -> (Data, URLResponse) { + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + let session = URLSession( + configuration: configuration, + delegate: self, + delegateQueue: nil + ) + let task = session.dataTask(with: request) + + self.lock.lock() + if self.cancellationRequested { + self.isFinished = true + self.lock.unlock() + session.invalidateAndCancel() + continuation.resume(throwing: CancellationError()) + return + } + self.continuation = continuation + self.session = session + self.lock.unlock() + + task.resume() + } + } onCancel: { + self.cancel() + } + } + + func urlSession( + _: URLSession, + task _: URLSessionTask, + willPerformHTTPRedirection _: HTTPURLResponse, + newRequest request: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void + ) { + guard let url = request.url, + url.scheme?.lowercased() == self.scheme, + url.host?.lowercased() == self.host, + Self.effectivePort(for: url) == self.port + else { + completionHandler(nil) + self.finish( + .failure(YouTubeAskTransportError.invalidResponse), + cancelSession: true + ) + return + } + completionHandler(request) + } + + func urlSession( + _: URLSession, + dataTask _: URLSessionDataTask, + didReceive response: URLResponse, + completionHandler: @escaping (URLSession.ResponseDisposition) -> Void + ) { + let exceedsLimit = response.expectedContentLength > 0 + && response.expectedContentLength > Int64(self.maximumBytes) + guard !exceedsLimit else { + completionHandler(.cancel) + self.finish( + .failure(YouTubeAskTransportError.responseTooLarge), + cancelSession: true + ) + return + } + + self.lock.lock() + if !self.isFinished { + self.response = response + if response.expectedContentLength > 0 { + self.responseData.reserveCapacity( + min(Int(response.expectedContentLength), self.maximumBytes) + ) + } + } + self.lock.unlock() + completionHandler(.allow) + } + + func urlSession( + _: URLSession, + dataTask _: URLSessionDataTask, + didReceive data: Data + ) { + var exceedsLimit = false + self.lock.lock() + if !self.isFinished { + if data.count > self.maximumBytes - self.responseData.count { + exceedsLimit = true + } else { + self.responseData.append(data) + } + } + self.lock.unlock() + + if exceedsLimit { + self.finish( + .failure(YouTubeAskTransportError.responseTooLarge), + cancelSession: true + ) + } + } + + func urlSession( + _: URLSession, + task _: URLSessionTask, + didCompleteWithError error: (any Error)? + ) { + if let error { + if (error as? URLError)?.code == .cancelled { + self.finish(.failure(CancellationError()), cancelSession: true) + } else { + self.finish(.failure(error), cancelSession: true) + } + return + } + + self.lock.lock() + let response = self.response + let data = self.responseData + self.lock.unlock() + + guard let response else { + self.finish( + .failure(YouTubeAskTransportError.invalidResponse), + cancelSession: true + ) + return + } + self.finish(.success((data, response)), cancelSession: false) + } + + private func cancel() { + self.lock.lock() + self.cancellationRequested = true + let shouldFinish = self.continuation != nil && !self.isFinished + self.lock.unlock() + if shouldFinish { + self.finish(.failure(CancellationError()), cancelSession: true) + } + } + + private func finish( + _ result: Result<(Data, URLResponse), any Error>, + cancelSession: Bool + ) { + self.lock.lock() + guard !self.isFinished else { + self.lock.unlock() + return + } + self.isFinished = true + let continuation = self.continuation + let session = self.session + self.continuation = nil + self.session = nil + self.lock.unlock() + + if cancelSession { + session?.invalidateAndCancel() + } else { + session?.finishTasksAndInvalidate() + } + continuation?.resume(with: result) + } + + private static func effectivePort(for url: URL) -> Int? { + if let port = url.port { + return port + } + switch url.scheme?.lowercased() { + case "https": + return 443 + case "http": + return 80 + default: + return nil + } + } +} diff --git a/Sources/Kaset/Services/API/YouTubeClient+Ask.swift b/Sources/Kaset/Services/API/YouTubeClient+Ask.swift new file mode 100644 index 000000000..48f5d8366 --- /dev/null +++ b/Sources/Kaset/Services/API/YouTubeClient+Ask.swift @@ -0,0 +1,147 @@ +import Foundation +import YouTubeAskCore + +extension YouTubeClient { + func loadAskConversation( + from bootstrap: YouTubeAskBootstrap + ) async throws -> YouTubeAskConversation { + let snapshot = try await self.makeAskRequestSnapshot(videoID: bootstrap.videoID) + guard bootstrap.isBound( + toVideoID: snapshot.videoID, + authenticationGeneration: snapshot.authenticationGeneration, + accountBinding: snapshot.accountBinding, + clientGeneration: snapshot.clientGeneration + ) else { + throw CancellationError() + } + + if !bootstrap.requiresPanelMaterialization { + return YouTubeAskConversation.direct(from: bootstrap) + } + + guard let command = bootstrap.materializationCommand else { + throw YouTubeAskClientError.invalidResponse + } + guard self.consumeAskBootstrap(conversationID: bootstrap.conversationID) else { + throw CancellationError() + } + let bodyData = YouTubeAskRequestBuilder.makePanelBootstrapBody(command: command) + let parsed = try await self.performAskPanelRequest( + bodyData: bodyData, + snapshot: snapshot + ) + guard !parsed.suggestions.isEmpty else { + throw YouTubeAskClientError.invalidResponse + } + return YouTubeAskConversation.materialized( + from: bootstrap, + parsed: parsed + ) + } + + func continueAskConversation( + _ conversation: YouTubeAskConversation, + selecting suggestionID: YouTubeAskSuggestion.ID + ) async throws -> YouTubeAskConversation { + guard let videoID = conversation.boundVideoID else { + throw CancellationError() + } + let snapshot = try await self.makeAskRequestSnapshot(videoID: videoID) + guard conversation.isBound( + toVideoID: snapshot.videoID, + authenticationGeneration: snapshot.authenticationGeneration, + accountBinding: snapshot.accountBinding, + clientGeneration: snapshot.clientGeneration + ), let command = conversation.command(for: suggestionID) + else { + throw CancellationError() + } + guard self.consumeAskRevision( + conversationID: conversation.id, + revision: conversation.revision + ) else { + throw CancellationError() + } + + let bodyData: Data + do { + bodyData = try YouTubeAskRequestBuilder.makeDirectChipBody( + command: command, + clientMessageID: self.nextAskClientMessageID() + ) + } catch { + throw YouTubeAskClientError.invalidResponse + } + let parsed = try await self.performAskPanelRequest( + bodyData: bodyData, + snapshot: snapshot + ) + guard !parsed.messages.isEmpty, + let nextConversation = YouTubeAskConversation.continued( + from: conversation, + parsed: parsed + ) + else { + throw YouTubeAskClientError.invalidResponse + } + return nextConversation + } + + private func performAskPanelRequest( + bodyData: Data, + snapshot: AskRequestSnapshot + ) async throws -> YouTubeAskParsedConversation { + try self.validateAskRequestSnapshot(snapshot) + let request = try self.makeAskRequest( + endpoint: "get_panel", + bodyData: bodyData, + snapshot: snapshot + ) + + let response: YouTubeAskHTTPResponse + do { + response = try await self.askTransport.send(request) + } catch is CancellationError { + throw CancellationError() + } catch YouTubeAskTransportError.responseTooLarge { + throw YouTubeAskClientError.responseTooLarge + } catch YouTubeAskTransportError.invalidResponse { + throw YouTubeAskClientError.invalidResponse + } catch { + throw YouTubeAskClientError.unavailable + } + try self.validateAskRequestSnapshot(snapshot) + + switch response.statusCode { + case 200 ... 299: + break + case 401, 403: + // Match YouTubeClient's established InnerTube auth handling: both + // statuses expire the matching identity generation, never a newer + // session that signed in while this request was in flight. + self.handleAskAuthenticationFailure(snapshot: snapshot) + throw YouTubeAskClientError.authenticationRequired + case 429: + throw YouTubeAskClientError.rateLimited + default: + throw YouTubeAskClientError.unavailable + } + + let parsed: YouTubeAskParsedConversation + do { + parsed = try await Task.detached(priority: .userInitiated) { + let envelope = try YouTubeAskWireDecoder.decode(response.data) + return try YouTubeAskParser.parseConversation(from: envelope) + }.value + } catch is CancellationError { + throw CancellationError() + } catch YouTubeAskCoreError.responseTooLarge { + throw YouTubeAskClientError.responseTooLarge + } catch { + throw YouTubeAskClientError.invalidResponse + } + + try self.validateAskRequestSnapshot(snapshot) + return parsed + } +} diff --git a/Sources/Kaset/Services/API/YouTubeClient.swift b/Sources/Kaset/Services/API/YouTubeClient.swift index c27077998..67c4c7e31 100644 --- a/Sources/Kaset/Services/API/YouTubeClient.swift +++ b/Sources/Kaset/Services/API/YouTubeClient.swift @@ -1,6 +1,7 @@ // swiftlint:disable file_length import Foundation import os +import YouTubeAskCore // MARK: - YouTubeClient @@ -20,6 +21,9 @@ final class YouTubeClient: YouTubeClientProtocol { // swiftlint:disable:this typ private let authService: AuthService private let webKitManager: WebKitManager private let session: URLSession + let askTransport: YouTubeAskTransport + let askMessageIDGenerator: YouTubeAskMessageIDGenerator + private let askRequestProfile: YouTubeAskRequestProfile? private let logger = DiagnosticsLogger.api /// Provider for the current brand account ID (mirrors `YTMusicClient`). @@ -32,6 +36,10 @@ final class YouTubeClient: YouTubeClientProtocol { // swiftlint:disable:this typ /// account responses across sign-in/account changes. var accountCacheIdentityProvider: (() -> String?)? + /// Provider for a verified primary-account scope eligible for Ask Gemini. + /// `nil` covers signed-out, guest, unresolved, and brand-account states. + var askAccountBindingProvider: (() -> YouTubeAskAccountBinding?)? + /// YouTube API base URL. private static let baseURL = "https://www.youtube.com/youtubei/v1" @@ -40,7 +48,7 @@ final class YouTubeClient: YouTubeClientProtocol { // swiftlint:disable:this typ /// Client version for WEB (live value observed June 2026; InnerTube /// accepts moderately stale versions). - private static let clientVersion = "2.20260611.01.00" + private static let clientVersion = YouTubeAskRequestProfile.productionClientVersion /// Cache-key prefix so YouTube entries never collide with music /// invalidation patterns ("browse:", "next:", …). @@ -48,6 +56,9 @@ final class YouTubeClient: YouTubeClientProtocol { // swiftlint:disable:this typ private var homeContinuation: String? private var searchContinuation: String? + private var askSessionGeneration: UInt64 = 0 + private var consumedAskBootstraps: Set = [] + private var consumedAskRevisions: Set = [] var hasMoreHomeFeed: Bool { self.homeContinuation != nil @@ -56,21 +67,32 @@ final class YouTubeClient: YouTubeClientProtocol { // swiftlint:disable:this typ func resetSessionStateForAccountSwitch() { self.homeContinuation = nil self.searchContinuation = nil + self.askSessionGeneration &+= 1 + self.consumedAskBootstraps = [] + self.consumedAskRevisions = [] } init( authService: AuthService, webKitManager: WebKitManager = .shared, - session: URLSession? = nil + session: URLSession? = nil, + askMessageIDGenerator: YouTubeAskMessageIDGenerator? = nil, + askFeatureEnabled: Bool = false ) { self.authService = authService self.webKitManager = webKitManager - if let session { - self.session = session + let resolvedSession: URLSession = if let session { + session } else { - self.session = URLSession(configuration: APISessionConfiguration.make()) + URLSession(configuration: APISessionConfiguration.make()) } + self.session = resolvedSession + self.askTransport = YouTubeAskTransport(configuration: resolvedSession.configuration) + self.askMessageIDGenerator = askMessageIDGenerator ?? YouTubeAskMessageIDGenerator() + // The July 28, 2026 read-only parity run did not establish a passing + // request profile. Production remains fail-closed; tests opt in explicitly. + self.askRequestProfile = askFeatureEnabled ? .fixedProduction : nil } // MARK: - Home Feed @@ -255,6 +277,62 @@ final class YouTubeClient: YouTubeClientProtocol { // swiftlint:disable:this typ return WatchNextParser.parse(data) } + func getWatchPage(videoId: String) async throws -> YouTubeWatchPage { + self.logger.info("Fetching YouTube watch page") + + let accountBinding = self.currentAskAccountBinding() + let askGeneration = self.askSessionGeneration + let requestAuth = try await self.buildRequestHeaders(authPolicy: .optional) + let data = try await self.requestData( + "next", + body: ["videoId": videoId], + requestAuth: requestAuth + ) + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw YTMusicError.parseError(message: "Watch response is not a JSON object") + } + + let watchData = WatchNextParser.parse(json) + guard requestAuth.authenticated, + let authenticationGeneration = requestAuth.authIdentityGeneration, + let accountBinding, + askGeneration == self.askSessionGeneration, + accountBinding == self.currentAskAccountBinding() + else { + return YouTubeWatchPage(data: watchData, askBootstrap: nil) + } + + let parsedBootstrap: YouTubeAskParsedBootstrap? + do { + parsedBootstrap = try await Task.detached(priority: .userInitiated) { + let envelope = try YouTubeAskWireDecoder.decode(data) + return try YouTubeAskParser.parseBootstrap(from: envelope) + }.value + try self.validateAskIdentity( + authenticationGeneration: authenticationGeneration, + accountBinding: accountBinding, + clientGeneration: askGeneration + ) + } catch is CancellationError { + throw CancellationError() + } catch { + self.logger.warning("Ask bootstrap rejected by strict parser") + return YouTubeWatchPage(data: watchData, askBootstrap: nil) + } + + guard let parsedBootstrap else { + return YouTubeWatchPage(data: watchData, askBootstrap: nil) + } + let bootstrap = YouTubeAskBootstrap.production( + videoID: videoId, + parsed: parsedBootstrap, + authenticationGeneration: authenticationGeneration, + accountBinding: accountBinding, + clientGeneration: askGeneration + ) + return YouTubeWatchPage(data: watchData, askBootstrap: bootstrap) + } + func getComments(continuation: String) async throws -> YouTubeCommentsPage { self.logger.info("Fetching YouTube comments page") @@ -905,6 +983,150 @@ final class YouTubeClient: YouTubeClientProtocol { // swiftlint:disable:this typ } } + // MARK: - Ask Request Identity + + private struct AskRevisionKey: Hashable { + let conversationID: UUID + let revision: UInt64 + } + + struct AskRequestSnapshot: CustomStringConvertible, CustomDebugStringConvertible, CustomReflectable { + let videoID: String + let authenticationGeneration: UInt64 + let accountBinding: YouTubeAskAccountBinding + let clientGeneration: UInt64 + let headers: [String: String] + let context: [String: Any] + + var description: String { + "" + } + + var debugDescription: String { + self.description + } + + var customMirror: Mirror { + Mirror(reflecting: self.description) + } + } + + func makeAskRequestSnapshot(videoID: String) async throws -> AskRequestSnapshot { + guard self.authService.hasPersonalAccount, + let accountBinding = self.currentAskAccountBinding() + else { + throw YouTubeAskClientError.authenticationRequired + } + let requestAuth: RequestAuthHeaders + do { + requestAuth = try await self.buildRequestHeaders(authPolicy: .required) + } catch is CancellationError { + throw CancellationError() + } catch { + throw YouTubeAskClientError.authenticationRequired + } + guard requestAuth.authenticated, + let authenticationGeneration = requestAuth.authIdentityGeneration + else { + throw YouTubeAskClientError.authenticationRequired + } + let snapshot = AskRequestSnapshot( + videoID: videoID, + authenticationGeneration: authenticationGeneration, + accountBinding: accountBinding, + clientGeneration: self.askSessionGeneration, + headers: requestAuth.headers, + context: self.buildContext(authenticated: true) + ) + try self.validateAskRequestSnapshot(snapshot) + return snapshot + } + + func validateAskRequestSnapshot(_ snapshot: AskRequestSnapshot) throws { + try self.validateAskIdentity( + authenticationGeneration: snapshot.authenticationGeneration, + accountBinding: snapshot.accountBinding, + clientGeneration: snapshot.clientGeneration + ) + } + + func validateAskIdentity( + authenticationGeneration: UInt64, + accountBinding: YouTubeAskAccountBinding, + clientGeneration: UInt64 + ) throws { + guard self.authService.hasPersonalAccount, + authenticationGeneration == self.authService.accountIdentityGeneration, + clientGeneration == self.askSessionGeneration, + accountBinding == self.currentAskAccountBinding() + else { + throw CancellationError() + } + } + + func makeAskRequest( + endpoint: String, + bodyData: Data, + snapshot: AskRequestSnapshot + ) throws -> URLRequest { + guard endpoint == "get_panel", + var body = try JSONSerialization.jsonObject(with: bodyData) as? [String: Any] + else { + throw YouTubeAskClientError.invalidResponse + } + body["context"] = snapshot.context + + var components = URLComponents(string: "\(Self.baseURL)/\(endpoint)") + components?.queryItems = [ + URLQueryItem(name: "prettyPrint", value: "false"), + ] + guard let url = components?.url else { + throw YouTubeAskClientError.invalidResponse + } + + var request = URLRequest( + url: url, + cachePolicy: .reloadIgnoringLocalCacheData, + timeoutInterval: 30 + ) + request.httpMethod = "POST" + request.httpShouldHandleCookies = false + for (key, value) in snapshot.headers { + request.setValue(value, forHTTPHeaderField: key) + } + request.httpBody = try JSONSerialization.data(withJSONObject: body) + return request + } + + func consumeAskBootstrap(conversationID: UUID) -> Bool { + self.consumedAskBootstraps.insert(conversationID).inserted + } + + func consumeAskRevision( + conversationID: UUID, + revision: UInt64 + ) -> Bool { + self.consumedAskRevisions.insert(AskRevisionKey( + conversationID: conversationID, + revision: revision + )).inserted + } + + func handleAskAuthenticationFailure(snapshot: AskRequestSnapshot) { + self.authService.sessionExpired( + ifIdentityGenerationMatches: snapshot.authenticationGeneration + ) + } + + func nextAskClientMessageID() -> String { + self.askMessageIDGenerator.next() + } + + private func currentAskAccountBinding() -> YouTubeAskAccountBinding? { + guard self.askRequestProfile != nil, self.authService.hasPersonalAccount else { return nil } + return self.askAccountBindingProvider?() + } + private func validateAuthIdentity(authenticated: Bool, generation: UInt64?) throws { guard authenticated else { return } guard let generation, diff --git a/Sources/Kaset/Services/YouTubeProtocols.swift b/Sources/Kaset/Services/YouTubeProtocols.swift index a8dba3ea1..54c832e72 100644 --- a/Sources/Kaset/Services/YouTubeProtocols.swift +++ b/Sources/Kaset/Services/YouTubeProtocols.swift @@ -57,6 +57,22 @@ protocol YouTubeClientProtocol: Sendable { /// Fetches watch-page companion data (metadata + related videos). func getWatchNext(videoId: String) async throws -> WatchNextData + /// Fetches normal watch data and an optional Ask Gemini bootstrap from one + /// shared `next` response. + func getWatchPage(videoId: String) async throws -> YouTubeWatchPage + + /// Lazily materializes an Ask panel, or promotes direct bootstrap chips into + /// a conversation without generating an answer. + func loadAskConversation( + from bootstrap: YouTubeAskBootstrap + ) async throws -> YouTubeAskConversation + + /// Submits exactly one server-issued suggestion in the current conversation. + func continueAskConversation( + _ conversation: YouTubeAskConversation, + selecting suggestionID: YouTubeAskSuggestion.ID + ) async throws -> YouTubeAskConversation + /// Fetches a page of comments by continuation token. func getComments(continuation: String) async throws -> YouTubeCommentsPage diff --git a/Sources/Kaset/Utilities/UITestConfig.swift b/Sources/Kaset/Utilities/UITestConfig.swift index c8a34fe24..760be875c 100644 --- a/Sources/Kaset/Utilities/UITestConfig.swift +++ b/Sources/Kaset/Utilities/UITestConfig.swift @@ -46,6 +46,9 @@ enum UITestConfig { /// When true, force logged-out state in UI tests. static let mockLoggedOutKey = "MOCK_LOGGED_OUT" + /// When true, expose synthetic Ask Gemini eligibility on watch pages. + static let mockAskGeminiEnabledKey = "MOCK_ASK_GEMINI_ENABLED" + /// When true, the mock client returns HTTP 404 from `getPodcasts()` /// to simulate a region where YouTube Music does not offer the /// Podcasts discovery surface. Used to UI-test sidebar visibility. diff --git a/Tests/KasetTests/Helpers/MockYouTubeClient.swift b/Tests/KasetTests/Helpers/MockYouTubeClient.swift index 7aa5f48e6..809fa1939 100644 --- a/Tests/KasetTests/Helpers/MockYouTubeClient.swift +++ b/Tests/KasetTests/Helpers/MockYouTubeClient.swift @@ -20,6 +20,11 @@ final class MockYouTubeClient: YouTubeClientProtocol { var searchResponsesByRequest: [String: YouTubeSearchResponse] = [:] var searchContinuation: YouTubeSearchResponse? var watchNextData = WatchNextData.empty + var askBootstrap: YouTubeAskBootstrap? + var watchPages: [YouTubeWatchPage] = [] + var askConversation = YouTubeAskConversation.testing() + var continuedAskConversation: YouTubeAskConversation? + var askError: Error? var channelDetail: YouTubeChannelDetail? var playlistDetail: YouTubePlaylistDetail? @@ -51,6 +56,10 @@ final class MockYouTubeClient: YouTubeClientProtocol { self.homeFeedContinuation = nil self.homeContinuationPages = [] self.searchContinuation = nil + self.askBootstrap = nil + self.watchPages = [] + self.askConversation = YouTubeAskConversation.testing() + self.continuedAskConversation = nil } /// Optional queue of continuation pages, consumed front-to-back by @@ -216,22 +225,104 @@ final class MockYouTubeClient: YouTubeClientProtocol { return self.watchNextData } + private(set) var getWatchPageCallCount = 0 + private(set) var loadAskConversationCallCount = 0 + private(set) var continueAskConversationCallCount = 0 + private(set) var selectedAskSuggestionIDs: [YouTubeAskSuggestion.ID] = [] + var beforeWatchPageReturn: (@Sendable () async -> Void)? + var beforeWatchPageReturnByCallCount: (@Sendable (Int) async -> Void)? + var beforeAskPreparationReturn: (@Sendable () async -> Void)? + var beforeAskContinuationReturn: (@Sendable () async -> Void)? + + func getWatchPage(videoId _: String) async throws -> YouTubeWatchPage { + self.getWatchPageCallCount += 1 + if let error { + throw error + } + if let beforeWatchPageReturn { + await beforeWatchPageReturn() + } + if let beforeWatchPageReturnByCallCount { + await beforeWatchPageReturnByCallCount(self.getWatchPageCallCount) + } + try Task.checkCancellation() + if !self.watchPages.isEmpty { + return self.watchPages.removeFirst() + } + return YouTubeWatchPage( + data: self.watchNextData, + askBootstrap: self.askBootstrap + ) + } + + func loadAskConversation( + from bootstrap: YouTubeAskBootstrap + ) async throws -> YouTubeAskConversation { + self.loadAskConversationCallCount += 1 + if let askError { + throw askError + } + if let beforeAskPreparationReturn { + await beforeAskPreparationReturn() + } + try Task.checkCancellation() + if self.askConversation.suggestions.isEmpty, !bootstrap.suggestions.isEmpty { + return YouTubeAskConversation.testing( + suggestions: bootstrap.suggestions.map(\.text) + ) + } + return self.askConversation + } + + func continueAskConversation( + _ conversation: YouTubeAskConversation, + selecting suggestionID: YouTubeAskSuggestion.ID + ) async throws -> YouTubeAskConversation { + self.continueAskConversationCallCount += 1 + self.selectedAskSuggestionIDs.append(suggestionID) + if let askError { + throw askError + } + if let beforeAskContinuationReturn { + await beforeAskContinuationReturn() + } + try Task.checkCancellation() + if let continuedAskConversation { + return continuedAskConversation + } + return YouTubeAskConversation.testing(messages: conversation.messages) + } + var commentsPage = YouTubeCommentsPage.empty private(set) var postedComments: [(text: String, params: String)] = [] private(set) var lastCommentsContinuation: String? + private(set) var getCommentsCallCount = 0 + private(set) var postCommentCallCount = 0 + var beforeCommentsReturn: (@Sendable (String) async -> Void)? + var beforePostCommentReturn: (@Sendable () async -> Void)? func getComments(continuation: String) async throws -> YouTubeCommentsPage { + self.getCommentsCallCount += 1 if let error { throw error } self.lastCommentsContinuation = continuation + if let beforeCommentsReturn { + await beforeCommentsReturn(continuation) + } + try Task.checkCancellation() return self.commentsPage } func postComment(text: String, createCommentParams: String) async throws { + self.postCommentCallCount += 1 if let error { throw error } + if let beforePostCommentReturn { + await beforePostCommentReturn() + } + try Task.checkCancellation() self.postedComments.append((text, createCommentParams)) } diff --git a/Tests/KasetTests/YouTubeAskClientTests.swift b/Tests/KasetTests/YouTubeAskClientTests.swift new file mode 100644 index 000000000..563c63d0b --- /dev/null +++ b/Tests/KasetTests/YouTubeAskClientTests.swift @@ -0,0 +1,761 @@ +import Foundation +import Testing +@testable import Kaset + +// MARK: - YouTubeAskClientTests + +@Suite("YouTube Ask client", .serialized, .tags(.api)) +struct YouTubeAskClientTests { + @Test("Watch page reuses one next request and exposes strict direct chips") + @MainActor + func watchPageReusesNextRequest() async throws { + let requestCount = LockedCounter() + let session = MockURLProtocol.makeMockSession { request in + #expect(requestCount.increment() == 1) + #expect(request.url?.path == "/youtubei/v1/next") + #expect(request.url?.query?.contains("key=") != true) + let authorization = request.value(forHTTPHeaderField: "Authorization") + #expect(authorization?.hasPrefix("SAPISIDHASH ") == true) + #expect(authorization?.contains("SAPISID1PHASH") != true) + #expect(authorization?.contains("SAPISID3PHASH") != true) + let body = try Self.body(from: request) + #expect(body["videoId"] as? String == "fixture-video") + let context = try #require(body["context"] as? [String: Any]) + let client = try #require(context["client"] as? [String: Any]) + #expect(client["clientVersion"] as? String == "2.20260611.01.00") + #expect(client["visitorData"] == nil) + + return Self.response(for: request, data: Self.eligibleNextData) + } + defer { MockURLProtocol.reset(session: session) } + + let (client, _) = try await Self.makeAuthenticatedClient(session: session) + let page = try await client.getWatchPage(videoId: "fixture-video") + + #expect(requestCount.count == 1) + let bootstrap = try #require(page.askBootstrap) + #expect(bootstrap.suggestions.map(\.text) == [ + "Explain the main idea", + "Résumer les points clés", + ]) + + let conversation = try await client.loadAskConversation(from: bootstrap) + #expect(requestCount.count == 1) + #expect(conversation.messages.isEmpty) + #expect(conversation.suggestions.map(\.text) == bootstrap.suggestions.map(\.text)) + } + + @Test("Production Ask domain state has redacted descriptions and reflection") + @MainActor + func productionAskDomainStateIsRedacted() async throws { + let session = MockURLProtocol.makeMockSession { request in + Self.response(for: request, data: Self.eligibleNextData) + } + defer { MockURLProtocol.reset(session: session) } + + let (client, _) = try await Self.makeAuthenticatedClient(session: session) + let page = try await client.getWatchPage(videoId: "fixture-video") + let bootstrap = try #require(page.askBootstrap) + let conversation = try await client.loadAskConversation(from: bootstrap) + let accountBinding = YouTubeAskAccountBinding(scopeID: "fixture-primary-scope") + let requestSnapshot = try await client.makeAskRequestSnapshot(videoID: "fixture-video") + let httpResponse = YouTubeAskHTTPResponse(data: Data(#"{}"#.utf8), statusCode: 200) + + #expect(String(describing: bootstrap) == "") + #expect(String(reflecting: bootstrap) == "") + #expect(String(describing: conversation) == "") + #expect(String(reflecting: conversation) == "") + #expect(String(describing: accountBinding) == "") + #expect(String(reflecting: accountBinding) == "") + #expect(String(describing: requestSnapshot) == "") + #expect(String(reflecting: requestSnapshot) == "") + #expect(String(describing: httpResponse) == "") + #expect(String(reflecting: httpResponse) == "") + } + + @Test("Panel materialization posts only continuation plus request context") + @MainActor + func materializationRequestShape() async throws { + let requestCount = LockedCounter() + let session = MockURLProtocol.makeMockSession { request in + switch requestCount.increment() { + case 1: + return Self.response(for: request, data: Self.panelOnlyNextData) + case 2: + #expect(request.url?.path == "/youtubei/v1/get_panel") + #expect(request.url?.query?.contains("key=") != true) + #expect(request.cachePolicy == .reloadIgnoringLocalCacheData) + let body = try Self.body(from: request) + #expect(Set(body.keys) == ["context", "continuation"]) + #expect(body["continuation"] as? String == "fixture-panel-continuation") + #expect(body["formData"] == nil) + return Self.response(for: request, data: Self.initialPanelData) + default: + Issue.record("Ask panel materialization retried unexpectedly") + return Self.response(for: request, data: Data(#"{}"#.utf8)) + } + } + defer { MockURLProtocol.reset(session: session) } + + let (client, _) = try await Self.makeAuthenticatedClient(session: session) + let page = try await client.getWatchPage(videoId: "fixture-video") + let conversation = try await client.loadAskConversation( + from: #require(page.askBootstrap) + ) + + #expect(requestCount.count == 2) + #expect(conversation.suggestions.map(\.text) == ["Ask a follow-up"]) + } + + @Test("Direct chips send exact fields and monotonic message IDs") + @MainActor + func directChipRequestShapeAndMessageIDs() async throws { + let requestCount = LockedCounter() + let messageIDs = LockedValues() + let session = MockURLProtocol.makeMockSession { request in + switch requestCount.increment() { + case 1: + return Self.response(for: request, data: Self.eligibleNextData) + case 2, 3: + let body = try Self.body(from: request) + #expect(Set(body.keys) == ["context", "continuation", "formData"]) + #expect(body["userInputText"] == nil) + #expect(body["clickTrackingParams"] == nil) + #expect(body["conversationId"] == nil) + let formData = try #require(body["formData"] as? [String: Any]) + let composer = try #require(formData["inputComposerFormData"] as? [String: Any]) + try messageIDs.append(#require(composer["clientMessageId"] as? String)) + return Self.response(for: request, data: Self.conversationData) + default: + Issue.record("Direct chip request retried unexpectedly") + return Self.response(for: request, data: Data(#"{}"#.utf8)) + } + } + defer { MockURLProtocol.reset(session: session) } + + let generator = YouTubeAskMessageIDGenerator(nowMilliseconds: { 1000 }) + let (client, _) = try await Self.makeAuthenticatedClient( + session: session, + messageIDGenerator: generator + ) + let page = try await client.getWatchPage(videoId: "fixture-video") + var conversation = try await client.loadAskConversation( + from: #require(page.askBootstrap) + ) + + let firstID = try #require(conversation.suggestions.first?.id) + let consumedConversation = try #require(conversation.appendingUserTurn(for: firstID)) + conversation = try await client.continueAskConversation( + consumedConversation, + selecting: firstID + ) + await #expect(throws: CancellationError.self) { + _ = try await client.continueAskConversation( + consumedConversation, + selecting: firstID + ) + } + + let secondID = try #require(conversation.suggestions.first?.id) + conversation = try #require(conversation.appendingUserTurn(for: secondID)) + _ = try await client.continueAskConversation( + conversation, + selecting: secondID + ) + + #expect(messageIDs.values == ["youchat-1000", "youchat-1001"]) + #expect(requestCount.count == 3) + } + + @Test("One chip prevents sibling reuse from the same revision") + @MainActor + func oneChipPreventsSiblingReuse() async throws { + let requestCount = LockedCounter() + let session = MockURLProtocol.makeMockSession { request in + switch requestCount.increment() { + case 1: + return Self.response(for: request, data: Self.eligibleNextData) + case 2: + return Self.response(for: request, data: Self.conversationData) + default: + Issue.record("A sibling chip reused an already-consumed revision") + return Self.response(for: request, data: Self.conversationData) + } + } + defer { MockURLProtocol.reset(session: session) } + + let (client, _) = try await Self.makeAuthenticatedClient(session: session) + let page = try await client.getWatchPage(videoId: "fixture-video") + let conversation = try await client.loadAskConversation( + from: #require(page.askBootstrap) + ) + let firstID = try #require(conversation.suggestions.first?.id) + let secondID = try #require(conversation.suggestions.dropFirst().first?.id) + let firstTurn = try #require(conversation.appendingUserTurn(for: firstID)) + let secondTurn = try #require(conversation.appendingUserTurn(for: secondID)) + + _ = try await client.continueAskConversation(firstTurn, selecting: firstID) + await #expect(throws: CancellationError.self) { + _ = try await client.continueAskConversation(secondTurn, selecting: secondID) + } + #expect(requestCount.count == 2) + } + + @Test("The visible user turn must match the submitted suggestion command") + @MainActor + func visibleTurnMustMatchSubmittedSuggestion() async throws { + let requestCount = LockedCounter() + let session = MockURLProtocol.makeMockSession { request in + switch requestCount.increment() { + case 1: + return Self.response(for: request, data: Self.eligibleNextData) + case 2: + return Self.response(for: request, data: Self.conversationData) + default: + Issue.record("A mismatched suggestion triggered an unexpected request") + return Self.response(for: request, data: Self.conversationData) + } + } + defer { MockURLProtocol.reset(session: session) } + + let (client, _) = try await Self.makeAuthenticatedClient(session: session) + let page = try await client.getWatchPage(videoId: "fixture-video") + let conversation = try await client.loadAskConversation( + from: #require(page.askBootstrap) + ) + let firstID = try #require(conversation.suggestions.first?.id) + let secondID = try #require(conversation.suggestions.dropFirst().first?.id) + let firstTurn = try #require(conversation.appendingUserTurn(for: firstID)) + + await #expect(throws: CancellationError.self) { + _ = try await client.continueAskConversation(firstTurn, selecting: secondID) + } + #expect(requestCount.count == 1) + + _ = try await client.continueAskConversation(firstTurn, selecting: firstID) + #expect(requestCount.count == 2) + } + + @Test("Concurrent sibling chips cannot fork one revision") + @MainActor + func concurrentSiblingChipsCannotForkRevision() async throws { + let requestCount = LockedCounter() + let session = MockURLProtocol.makeMockSession { request in + switch requestCount.increment() { + case 1: + return Self.response(for: request, data: Self.eligibleNextData) + case 2, 3: + return Self.response(for: request, data: Self.conversationData) + default: + Issue.record("Ask request retried unexpectedly") + return Self.response(for: request, data: Self.conversationData) + } + } + defer { MockURLProtocol.reset(session: session) } + + let (client, _) = try await Self.makeAuthenticatedClient(session: session) + let page = try await client.getWatchPage(videoId: "fixture-video") + let conversation = try await client.loadAskConversation( + from: #require(page.askBootstrap) + ) + let firstID = try #require(conversation.suggestions.first?.id) + let secondID = try #require(conversation.suggestions.dropFirst().first?.id) + let firstTurn = try #require(conversation.appendingUserTurn(for: firstID)) + let secondTurn = try #require(conversation.appendingUserTurn(for: secondID)) + + let firstRequest = Task { + do { + _ = try await client.continueAskConversation(firstTurn, selecting: firstID) + return true + } catch is CancellationError { + return false + } catch { + Issue.record("Unexpected first-chip failure: \(error)") + return false + } + } + let secondRequest = Task { + do { + _ = try await client.continueAskConversation(secondTurn, selecting: secondID) + return true + } catch is CancellationError { + return false + } catch { + Issue.record("Unexpected second-chip failure: \(error)") + return false + } + } + + let outcomes = await [firstRequest.value, secondRequest.value] + #expect(outcomes.filter(\.self).count == 1) + #expect(requestCount.count == 2) + } + + @Test("Watch bootstrap is canceled when account scope changes during parsing") + @MainActor + func watchBootstrapRevalidatesAccountAfterParsing() async throws { + let session = MockURLProtocol.makeMockSession { request in + Self.response(for: request, data: Self.eligibleNextData) + } + defer { MockURLProtocol.reset(session: session) } + + let (client, _) = try await Self.makeAuthenticatedClient(session: session) + let bindingChecks = LockedCounter() + client.askAccountBindingProvider = { + let scopeID = bindingChecks.increment() <= 2 + ? "fixture-primary-scope" + : "fixture-replacement-scope" + return YouTubeAskAccountBinding(scopeID: scopeID) + } + + await #expect(throws: CancellationError.self) { + _ = try await client.getWatchPage(videoId: "fixture-video") + } + #expect(bindingChecks.count == 3) + } + + @Test("Conversation response is canceled when authentication changes during parsing") + @MainActor + func conversationRevalidatesAuthenticationAfterParsing() async throws { + let requestCount = LockedCounter() + let session = MockURLProtocol.makeMockSession { request in + switch requestCount.increment() { + case 1: + return Self.response(for: request, data: Self.eligibleNextData) + case 2: + return Self.response(for: request, data: Self.conversationData) + default: + Issue.record("Ask request retried unexpectedly") + return Self.response(for: request, data: Self.conversationData) + } + } + defer { MockURLProtocol.reset(session: session) } + + let (client, authService) = try await Self.makeAuthenticatedClient(session: session) + let page = try await client.getWatchPage(videoId: "fixture-video") + let conversation = try await client.loadAskConversation( + from: #require(page.askBootstrap) + ) + let selectedID = try #require(conversation.suggestions.first?.id) + let selectedTurn = try #require(conversation.appendingUserTurn(for: selectedID)) + + let bindingChecks = LockedCounter() + client.askAccountBindingProvider = { + if bindingChecks.increment() == 4 { + authService.sessionExpired() + } + return YouTubeAskAccountBinding(scopeID: "fixture-primary-scope") + } + + await #expect(throws: CancellationError.self) { + _ = try await client.continueAskConversation(selectedTurn, selecting: selectedID) + } + #expect(bindingChecks.count == 4) + #expect(requestCount.count == 2) + } + + @Test("Panel rate limits are mapped without retry") + @MainActor + func rateLimitDoesNotRetry() async throws { + let requestCount = LockedCounter() + let session = MockURLProtocol.makeMockSession { request in + switch requestCount.increment() { + case 1: + return Self.response(for: request, data: Self.panelOnlyNextData) + case 2: + return Self.response(for: request, data: Data(#"{}"#.utf8), statusCode: 429) + default: + Issue.record("Rate-limited Ask request retried unexpectedly") + return Self.response(for: request, data: Data(#"{}"#.utf8), statusCode: 429) + } + } + defer { MockURLProtocol.reset(session: session) } + + let (client, _) = try await Self.makeAuthenticatedClient(session: session) + let page = try await client.getWatchPage(videoId: "fixture-video") + + await #expect(throws: YouTubeAskClientError.rateLimited) { + _ = try await client.loadAskConversation( + from: #require(page.askBootstrap) + ) + } + #expect(requestCount.count == 2) + } + + @Test("Panel authentication failures invalidate the matching session", arguments: [401, 403]) + @MainActor + func authenticationFailureMapping(statusCode: Int) async throws { + let requestCount = LockedCounter() + let session = MockURLProtocol.makeMockSession { request in + switch requestCount.increment() { + case 1: + return Self.response(for: request, data: Self.panelOnlyNextData) + case 2: + return Self.response(for: request, data: Data(#"{}"#.utf8), statusCode: statusCode) + default: + Issue.record("Authentication failure retried unexpectedly") + return Self.response(for: request, data: Data(#"{}"#.utf8), statusCode: statusCode) + } + } + defer { MockURLProtocol.reset(session: session) } + + let (client, authService) = try await Self.makeAuthenticatedClient(session: session) + let identityGeneration = authService.accountIdentityGeneration + let page = try await client.getWatchPage(videoId: "fixture-video") + + await #expect(throws: YouTubeAskClientError.authenticationRequired) { + _ = try await client.loadAskConversation(from: #require(page.askBootstrap)) + } + + #expect(requestCount.count == 2) + #expect(!authService.hasPersonalAccount) + #expect(authService.accountIdentityGeneration == identityGeneration &+ 1) + } + + @Test("Malformed successful panel responses fail without retry") + @MainActor + func malformedPanelResponseMapping() async throws { + let requestCount = LockedCounter() + let session = MockURLProtocol.makeMockSession { request in + switch requestCount.increment() { + case 1: + return Self.response(for: request, data: Self.panelOnlyNextData) + case 2: + return Self.response(for: request, data: Data(#"{}"#.utf8)) + default: + Issue.record("Malformed panel response retried unexpectedly") + return Self.response(for: request, data: Data(#"{}"#.utf8)) + } + } + defer { MockURLProtocol.reset(session: session) } + + let (client, _) = try await Self.makeAuthenticatedClient(session: session) + let page = try await client.getWatchPage(videoId: "fixture-video") + + await #expect(throws: YouTubeAskClientError.invalidResponse) { + _ = try await client.loadAskConversation(from: #require(page.askBootstrap)) + } + #expect(requestCount.count == 2) + } + + @Test("Panel network failures map to unavailable without retry") + @MainActor + func panelNetworkFailureMapping() async throws { + let requestCount = LockedCounter() + let session = MockURLProtocol.makeMockSession { request in + switch requestCount.increment() { + case 1: + return Self.response(for: request, data: Self.panelOnlyNextData) + case 2: + throw URLError(.timedOut) + default: + Issue.record("Panel network failure retried unexpectedly") + throw URLError(.timedOut) + } + } + defer { MockURLProtocol.reset(session: session) } + + let (client, _) = try await Self.makeAuthenticatedClient(session: session) + let page = try await client.getWatchPage(videoId: "fixture-video") + + await #expect(throws: YouTubeAskClientError.unavailable) { + _ = try await client.loadAskConversation(from: #require(page.askBootstrap)) + } + #expect(requestCount.count == 2) + } + + @Test("Signed-out and reset sessions reject old Ask state before sending") + @MainActor + func staleSessionStateIsRejectedBeforeSending() async throws { + let requestCount = LockedCounter() + let session = MockURLProtocol.makeMockSession { request in + #expect(requestCount.increment() == 1) + return Self.response(for: request, data: Self.eligibleNextData) + } + defer { MockURLProtocol.reset(session: session) } + + let (client, authService) = try await Self.makeAuthenticatedClient(session: session) + let page = try await client.getWatchPage(videoId: "fixture-video") + let bootstrap = try #require(page.askBootstrap) + let conversation = try await client.loadAskConversation(from: bootstrap) + let suggestionID = try #require(conversation.suggestions.first?.id) + let pendingConversation = try #require(conversation.appendingUserTurn(for: suggestionID)) + + client.resetSessionStateForAccountSwitch() + + await #expect(throws: CancellationError.self) { + _ = try await client.loadAskConversation(from: bootstrap) + } + await #expect(throws: CancellationError.self) { + _ = try await client.continueAskConversation( + pendingConversation, + selecting: suggestionID + ) + } + #expect(requestCount.count == 1) + + authService.sessionExpired() + await #expect(throws: YouTubeAskClientError.authenticationRequired) { + _ = try await client.loadAskConversation(from: bootstrap) + } + #expect(requestCount.count == 1) + } + + @Test("Production remains disabled when no parity profile has passed") + @MainActor + func productionDefaultRemainsDisabled() async throws { + let session = MockURLProtocol.makeMockSession { request in + Self.response(for: request, data: Self.eligibleNextData) + } + defer { MockURLProtocol.reset(session: session) } + + let webKitManager = WebKitManager.makeTestInstance() + let authCookie = try #require(HTTPCookie(properties: [ + .name: "__Secure-3PAPISID", + .value: "mock-token", + .domain: ".youtube.com", + .path: "/", + ])) + await webKitManager.dataStore.httpCookieStore.setCookie(authCookie) + let authService = AuthService(webKitManager: webKitManager) + authService.completeLogin(sapisid: "mock-token") + let client = YouTubeClient( + authService: authService, + webKitManager: webKitManager, + session: session + ) + client.askAccountBindingProvider = { + YouTubeAskAccountBinding(scopeID: "fixture-primary-scope") + } + + let page = try await client.getWatchPage(videoId: "fixture-video") + #expect(page.askBootstrap == nil) + } + + @Test("Unresolved and unsupported account states omit Ask") + @MainActor + func unresolvedAccountOmitsAsk() async throws { + let session = MockURLProtocol.makeMockSession { request in + Self.response(for: request, data: Self.eligibleNextData) + } + defer { MockURLProtocol.reset(session: session) } + + let webKitManager = WebKitManager.makeTestInstance() + let authCookie = try #require(HTTPCookie(properties: [ + .name: "__Secure-3PAPISID", + .value: "mock-token", + .domain: ".youtube.com", + .path: "/", + ])) + await webKitManager.dataStore.httpCookieStore.setCookie(authCookie) + let authService = AuthService(webKitManager: webKitManager) + authService.completeLogin(sapisid: "mock-token") + let client = YouTubeClient( + authService: authService, + webKitManager: webKitManager, + session: session, + askFeatureEnabled: true + ) + + let page = try await client.getWatchPage(videoId: "fixture-video") + #expect(page.askBootstrap == nil) + } + + @MainActor + private static func makeAuthenticatedClient( + session: URLSession, + messageIDGenerator: YouTubeAskMessageIDGenerator? = nil + ) async throws -> (YouTubeClient, AuthService) { + let webKitManager = WebKitManager.makeTestInstance() + let authCookie = try #require(HTTPCookie(properties: [ + .name: "__Secure-3PAPISID", + .value: "mock-token", + .domain: ".youtube.com", + .path: "/", + ])) + await webKitManager.dataStore.httpCookieStore.setCookie(authCookie) + let authService = AuthService(webKitManager: webKitManager) + authService.completeLogin(sapisid: "mock-token") + let client = YouTubeClient( + authService: authService, + webKitManager: webKitManager, + session: session, + askMessageIDGenerator: messageIDGenerator, + askFeatureEnabled: true + ) + client.askAccountBindingProvider = { + YouTubeAskAccountBinding(scopeID: "fixture-primary-scope") + } + return (client, authService) + } + + private static func body(from request: URLRequest) throws -> [String: Any] { + let data: Data + if let httpBody = request.httpBody { + data = httpBody + } else if let stream = request.httpBodyStream { + stream.open() + defer { stream.close() } + var result = Data() + var buffer = [UInt8](repeating: 0, count: 4096) + while stream.hasBytesAvailable { + let count = stream.read(&buffer, maxLength: buffer.count) + guard count >= 0 else { break } + if count == 0 { + break + } + result.append(buffer, count: count) + } + data = result + } else { + throw YouTubeAskClientError.invalidResponse + } + return try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + } + + private static func response( + for request: URLRequest, + data: Data, + statusCode: Int = 200 + ) -> (HTTPURLResponse, Data) { + guard let url = request.url, + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + ) + else { + preconditionFailure("Synthetic HTTP response could not be created") + } + return (response, data) + } + + private static let eligibleNextData = Data( + #""" + { + "contents": {}, + "engagementPanels": [ + { + "engagementPanelSectionListRenderer": { + "panelIdentifier": "PAyouchat", + "content": { + "youChatItemViewModel": { + "chipsData": { + "chipData": [ + { + "text": {"simpleText": "Explain the main idea"}, + "continuation": "fixture-chip-a" + }, + { + "text": {"simpleText": "Résumer les points clés"}, + "continuation": "fixture-chip-b" + } + ] + } + } + } + } + } + ] + } + """#.utf8 + ) + + private static let panelOnlyNextData = Data( + #""" + { + "contents": {}, + "engagementPanels": [ + { + "engagementPanelSectionListRenderer": { + "panelIdentifier": "PAyouchat", + "continuationEndpoint": { + "continuationCommand": { + "request": "CONTINUATION_REQUEST_TYPE_GET_PANEL", + "token": "fixture-panel-continuation" + } + } + } + } + ] + } + """#.utf8 + ) + + private static let initialPanelData = Data( + #""" + { + "onResponseReceivedCommands": [ + { + "appendContinuationItemsAction": { + "continuationItems": [ + { + "youChatItemViewModel": { + "chipsData": { + "chipData": [ + { + "text": {"simpleText": "Ask a follow-up"}, + "continuation": "fixture-follow-up" + } + ] + } + } + } + ] + } + } + ] + } + """#.utf8 + ) + + private static let conversationData = Data( + #""" + { + "onResponseReceivedCommands": [ + { + "appendContinuationItemsAction": { + "continuationItems": [ + { + "youChatTextMessageViewModel": { + "text": {"content": "A synthetic assistant response."} + } + }, + { + "youChatItemViewModel": { + "chipsData": { + "chipData": [ + { + "text": {"simpleText": "Continue"}, + "continuation": "fixture-next-chip" + } + ] + } + } + } + ] + } + } + ] + } + """#.utf8 + ) +} + +// MARK: - LockedValues + +private final class LockedValues: @unchecked Sendable { + private let lock = NSLock() + private var storage: [Value] = [] + + func append(_ value: Value) { + self.lock.withLock { + self.storage.append(value) + } + } + + var values: [Value] { + self.lock.withLock { self.storage } + } +} diff --git a/Tests/KasetTests/YouTubeAskTransportTests.swift b/Tests/KasetTests/YouTubeAskTransportTests.swift new file mode 100644 index 000000000..c7d1631ab --- /dev/null +++ b/Tests/KasetTests/YouTubeAskTransportTests.swift @@ -0,0 +1,360 @@ +import Foundation +import Testing +@testable import Kaset + +// MARK: - YouTubeAskTransportTests + +@Suite("YouTube Ask transport", .serialized, .tags(.api)) +struct YouTubeAskTransportTests { + private static let maximumResponseBytes = 32 * 1024 * 1024 + + @Test("Same-origin redirects are followed") + func sameOriginRedirectIsFollowed() async throws { + let requestCount = LockedCounter() + AskTransportURLProtocol.setHandler { protocolInstance in + let request = protocolInstance.request + switch requestCount.increment() { + case 1: + guard let redirectURL = URL( + string: "https://www.youtube.com/youtubei/v1/get_panel?redirected=true" + ) else { + Issue.record("Synthetic redirect URL could not be created") + return + } + AskTransportURLProtocol.redirect( + protocolInstance, + to: URLRequest(url: redirectURL) + ) + case 2: + #expect(request.url?.host == "www.youtube.com") + #expect(request.url?.query == "redirected=true") + AskTransportURLProtocol.respond( + protocolInstance, + data: Data(#"{}"#.utf8) + ) + default: + Issue.record("Same-origin redirect performed too many requests") + AskTransportURLProtocol.fail(protocolInstance, with: URLError(.badServerResponse)) + } + } + defer { AskTransportURLProtocol.reset() } + + let response = try await self.makeTransport().send(self.makeRequest()) + + #expect(response.statusCode == 200) + #expect(response.data == Data(#"{}"#.utf8)) + #expect(requestCount.count == 2) + } + + @Test("Cross-origin redirects are rejected") + func crossOriginRedirectIsRejected() async { + let requestCount = LockedCounter() + AskTransportURLProtocol.setHandler { protocolInstance in + _ = requestCount.increment() + let redirectURL = URL(string: "https://example.invalid/blocked") + guard let redirectURL else { + Issue.record("Synthetic redirect URL could not be created") + return + } + AskTransportURLProtocol.redirect( + protocolInstance, + to: URLRequest(url: redirectURL) + ) + } + defer { AskTransportURLProtocol.reset() } + + await #expect(throws: YouTubeAskTransportError.invalidResponse) { + _ = try await self.makeTransport().send(self.makeRequest()) + } + #expect(requestCount.count == 1) + } + + @Test("Declared response sizes above the limit are rejected") + func declaredResponseOverflowIsRejected() async { + AskTransportURLProtocol.setHandler { protocolInstance in + AskTransportURLProtocol.respond( + protocolInstance, + data: Data(), + headers: [ + "Content-Length": String(Self.maximumResponseBytes + 1), + "Content-Type": "application/json", + ] + ) + } + defer { AskTransportURLProtocol.reset() } + + await #expect(throws: YouTubeAskTransportError.responseTooLarge) { + _ = try await self.makeTransport().send(self.makeRequest()) + } + } + + @Test("Streamed response sizes above the limit are rejected") + func streamedResponseOverflowIsRejected() async { + let chunk = Data(repeating: 0x61, count: 4 * 1024 * 1024) + AskTransportURLProtocol.setHandler { protocolInstance in + AskTransportURLProtocol.respond( + protocolInstance, + chunks: Array(repeating: chunk, count: 9), + headers: ["Content-Type": "application/json"] + ) + } + defer { AskTransportURLProtocol.reset() } + + await #expect(throws: YouTubeAskTransportError.responseTooLarge) { + _ = try await self.makeTransport().send(self.makeRequest()) + } + } + + @Test("Non-HTTP responses are rejected as malformed") + func nonHTTPResponseIsRejected() async { + AskTransportURLProtocol.setHandler { protocolInstance in + guard let url = protocolInstance.request.url else { + Issue.record("Synthetic request had no URL") + return + } + AskTransportURLProtocol.respond( + protocolInstance, + response: URLResponse( + url: url, + mimeType: "application/json", + expectedContentLength: 2, + textEncodingName: nil + ), + chunks: [Data(#"{}"#.utf8)] + ) + } + defer { AskTransportURLProtocol.reset() } + + await #expect(throws: YouTubeAskTransportError.invalidResponse) { + _ = try await self.makeTransport().send(self.makeRequest()) + } + } + + @Test("Malformed transport responses map to the Ask presentation error") + @MainActor + func malformedTransportResponseMapsToClientError() async throws { + let requestCount = LockedCounter() + AskTransportURLProtocol.setHandler { protocolInstance in + let request = protocolInstance.request + switch requestCount.increment() { + case 1: + AskTransportURLProtocol.respond( + protocolInstance, + data: Self.panelOnlyNextData + ) + case 2: + guard let url = request.url else { + Issue.record("Synthetic panel request had no URL") + return + } + AskTransportURLProtocol.respond( + protocolInstance, + response: URLResponse( + url: url, + mimeType: "application/json", + expectedContentLength: 2, + textEncodingName: nil + ), + chunks: [Data(#"{}"#.utf8)] + ) + default: + Issue.record("Malformed transport response retried unexpectedly") + AskTransportURLProtocol.fail(protocolInstance, with: URLError(.badServerResponse)) + } + } + defer { AskTransportURLProtocol.reset() } + + let client = try await self.makeAuthenticatedClient() + let page = try await client.getWatchPage(videoId: "fixture-video") + + await #expect(throws: YouTubeAskClientError.invalidResponse) { + _ = try await client.loadAskConversation(from: #require(page.askBootstrap)) + } + #expect(requestCount.count == 2) + } + + private func makeTransport() -> YouTubeAskTransport { + YouTubeAskTransport(configuration: self.makeConfiguration()) + } + + private func makeConfiguration() -> URLSessionConfiguration { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [AskTransportURLProtocol.self] + return configuration + } + + private func makeRequest() -> URLRequest { + guard let url = URL(string: "https://www.youtube.com/youtubei/v1/get_panel") else { + preconditionFailure("Synthetic Ask URL could not be created") + } + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.httpBody = Data(#"{}"#.utf8) + return request + } + + @MainActor + private func makeAuthenticatedClient() async throws -> YouTubeClient { + let webKitManager = WebKitManager.makeTestInstance() + let authCookie = try #require(HTTPCookie(properties: [ + .name: "__Secure-3PAPISID", + .value: "mock-token", + .domain: ".youtube.com", + .path: "/", + ])) + await webKitManager.dataStore.httpCookieStore.setCookie(authCookie) + let authService = AuthService(webKitManager: webKitManager) + authService.completeLogin(sapisid: "mock-token") + let client = YouTubeClient( + authService: authService, + webKitManager: webKitManager, + session: URLSession(configuration: self.makeConfiguration()), + askFeatureEnabled: true + ) + client.askAccountBindingProvider = { + YouTubeAskAccountBinding(scopeID: "fixture-primary-scope") + } + return client + } + + private static let panelOnlyNextData = Data( + #""" + { + "contents": {}, + "engagementPanels": [ + { + "engagementPanelSectionListRenderer": { + "panelIdentifier": "PAyouchat", + "continuationEndpoint": { + "continuationCommand": { + "request": "CONTINUATION_REQUEST_TYPE_GET_PANEL", + "token": "fixture-panel-continuation" + } + } + } + } + ] + } + """#.utf8 + ) +} + +// MARK: - AskTransportURLProtocol + +private final class AskTransportURLProtocol: URLProtocol { + typealias Handler = (AskTransportURLProtocol) -> Void + + private static let lock = NSLock() + // swiftlint:disable:next modifier_order + private nonisolated(unsafe) static var handler: Handler? + + override static func canInit(with _: URLRequest) -> Bool { + true + } + + override static func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + let handler = Self.lock.withLock { Self.handler } + guard let handler else { + Self.fail(self, with: URLError(.resourceUnavailable)) + return + } + handler(self) + } + + override func stopLoading() {} + + static func setHandler(_ handler: @escaping Handler) { + self.lock.withLock { + self.handler = handler + } + } + + static func reset() { + self.lock.withLock { + self.handler = nil + } + } + + static func respond( + _ protocolInstance: AskTransportURLProtocol, + data: Data, + statusCode: Int = 200, + headers: [String: String] = ["Content-Type": "application/json"] + ) { + self.respond( + protocolInstance, + chunks: [data], + statusCode: statusCode, + headers: headers + ) + } + + static func respond( + _ protocolInstance: AskTransportURLProtocol, + chunks: [Data], + statusCode: Int = 200, + headers: [String: String] = ["Content-Type": "application/json"] + ) { + guard let url = protocolInstance.request.url, + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: nil, + headerFields: headers + ) + else { + self.fail(protocolInstance, with: URLError(.badServerResponse)) + return + } + self.respond(protocolInstance, response: response, chunks: chunks) + } + + static func respond( + _ protocolInstance: AskTransportURLProtocol, + response: URLResponse, + chunks: [Data] + ) { + protocolInstance.client?.urlProtocol( + protocolInstance, + didReceive: response, + cacheStoragePolicy: .notAllowed + ) + for chunk in chunks { + protocolInstance.client?.urlProtocol(protocolInstance, didLoad: chunk) + } + protocolInstance.client?.urlProtocolDidFinishLoading(protocolInstance) + } + + static func redirect( + _ protocolInstance: AskTransportURLProtocol, + to request: URLRequest + ) { + guard let originalURL = protocolInstance.request.url, + let response = HTTPURLResponse( + url: originalURL, + statusCode: 302, + httpVersion: nil, + headerFields: ["Location": request.url?.absoluteString ?? ""] + ) + else { + self.fail(protocolInstance, with: URLError(.badServerResponse)) + return + } + protocolInstance.client?.urlProtocol( + protocolInstance, + wasRedirectedTo: request, + redirectResponse: response + ) + } + + static func fail( + _ protocolInstance: AskTransportURLProtocol, + with error: any Error + ) { + protocolInstance.client?.urlProtocol(protocolInstance, didFailWithError: error) + } +} diff --git a/Tests/KasetTests/YouTubeSingleFlightViewModelTests.swift b/Tests/KasetTests/YouTubeSingleFlightViewModelTests.swift index 5de71e2a2..fe7d596d2 100644 --- a/Tests/KasetTests/YouTubeSingleFlightViewModelTests.swift +++ b/Tests/KasetTests/YouTubeSingleFlightViewModelTests.swift @@ -333,6 +333,26 @@ private final class SingleFlightYouTubeClient: YouTubeClientProtocol { return self.watchNextData } + func getWatchPage(videoId _: String) async throws -> YouTubeWatchPage { + try await self.waitIfNeeded() + return YouTubeWatchPage(data: self.watchNextData, askBootstrap: nil) + } + + func loadAskConversation( + from _: YouTubeAskBootstrap + ) async throws -> YouTubeAskConversation { + try await self.waitIfNeeded() + return YouTubeAskConversation.testing() + } + + func continueAskConversation( + _ conversation: YouTubeAskConversation, + selecting _: YouTubeAskSuggestion.ID + ) async throws -> YouTubeAskConversation { + try await self.waitIfNeeded() + return conversation + } + func getComments(continuation _: String) async throws -> YouTubeCommentsPage { try await self.waitIfNeeded() return self.commentsPage diff --git a/docs/adr/0032-youtube-ask-gemini.md b/docs/adr/0032-youtube-ask-gemini.md new file mode 100644 index 000000000..838c2900d --- /dev/null +++ b/docs/adr/0032-youtube-ask-gemini.md @@ -0,0 +1,90 @@ +# ADR-0032: Watch-Scoped YouTube Ask Gemini + +## Status + +Accepted; production activation is disabled pending request-profile validation. + +## Context + +YouTube exposes an undocumented Ask Gemini / YouChat surface on some watch +pages. The surface is account-, rollout-, client-, and video-dependent. Its +responses combine user-visible messages and suggestion labels with opaque +server commands that must be replayed exactly. Those commands can carry +session- or conversation-specific state and must not become application data, +logs, fixtures, restoration state, or telemetry. + +Kaset normally retrieves product data through `YouTubeClient` and reserves +WebViews for authentication and DRM playback. Implementing Ask through watch-page +DOM automation would expand the WebView's responsibility, couple the feature to +frontend markup, and make account and lifecycle boundaries harder to enforce. +The app also needs a deliberately smaller first version than YouTube's full +surface: generating an answer from arbitrary text or adopting an unvalidated +streaming transport would require request fields and semantics that have not +been established safely. + +The exact production request profile is a separate compatibility question. A +successful exploratory request does not prove that the profile used by +`YouTubeClient` is accepted, so activation must be gated by a redacted, +read-only parity check rather than by inferred or guessed request fields. +Wire-level observations and the API Explorer workflow remain documented in the +[API discovery record](../api-discovery.md#youtube-ask-gemini--youchat-investigation-2026-07-27). + +## Decision + +1. **Use the API, not the playback WebView.** Ask discovery, panel + materialization, and suggestion submission belong to `YouTubeClient` and a + Foundation-only `YouTubeAskCore` parser/decoder layer. The existing watch + `next` response is shared with normal watch-page parsing. WebViews remain + limited to authentication and DRM playback; Kaset does not scrape or drive + the Ask Gemini DOM. +2. **Ship a chips-only v1.** The watch page may show a collapsed Ask Gemini card + above Related. Expanding it may prepare the initial panel, but never submits a + suggestion or generates an answer automatically. Only server-issued + suggestion chips and follow-up chips can be selected. Arbitrary text prompts, + a text composer, and `streaming_panel` are out of scope. +3. **Scope all conversation state to the current watch and account.** Ask is + available only to an eligible signed-in primary account. Hidden state is + bound to the video, authentication generation, primary-account scope, local + conversation ID, and conversation revision. Navigation away, source or + account changes, authentication changes, cancellation, or view-model + destruction discards the state. Conversations are memory-only and are never + stored in `APICache`, UserDefaults, Keychain, navigation restoration, + telemetry, or logs. +4. **Treat server commands as opaque capabilities.** Continuations and related + command objects have no printable, codable, raw-value, or persistence-facing + interface. Kaset preserves server order, replays only the exact command + selected by the user, and never substitutes the visible chip label or an + invented conversation field. Only sanitized visible messages and local IDs + cross into UI models. Server-provided suggestion labels and answers are + displayed verbatim and are not localized by Kaset. +5. **Fail closed on unsupported or ambiguous data.** Strict parsing recognizes + only confirmed YouChat structures, bounded wire formats, and supported + message/chip containers. Ambiguity, malformed or oversized responses, + unsupported decorators, identity changes, or uncertain submission outcomes + disable the current session. There is no automatic retry; the UI may offer + New Chat, which starts from a fresh watch bootstrap and replaces the old + conversation only after preparation succeeds. +6. **Require a passing request profile before production activation.** No + profile is currently selected. The production feature remains disabled and + fail-closed until a redacted parity run establishes a passing signed-in + primary-account profile. Added compatibility configuration, if any, must + remain isolated to Ask requests. The + [API discovery record](../api-discovery.md#youtube-ask-gemini--youchat-investigation-2026-07-27) + is the sole wire-level source of truth. + +## Consequences + +- Ask follows Kaset's API-over-WebView boundary and shares one strict parser and + safety implementation between the app and API Explorer. +- V1 cannot accept free-form questions and does not reproduce every YouTube Ask + capability. It can only replay suggestions YouTube issued for the current + conversation. +- Conversation continuity intentionally ends at the watch/account lifecycle + boundary and at app termination. +- Opaque command material is harder to inspect during debugging, but accidental + disclosure and cross-account reuse are substantially less likely. +- A completed UI and domain implementation may remain invisible when no request + profile has passed. This is intentional: HTTP success alone is not evidence of + authenticated eligibility. +- Future enablement requires updating the redacted parity result and tests, not + guessing request fields or weakening parser rules. diff --git a/docs/adr/README.md b/docs/adr/README.md index d02455a5c..711208be0 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -66,3 +66,4 @@ What becomes easier or more difficult because of this change? | [0029](0029-now-playing-tracklist-provider.md) | Shared Now-Playing Mix Tracklist Provider | Accepted | | [0030](0030-account-scoped-favorites.md) | Account-Scoped Favorites Persistence | Accepted | | [0031](0031-saved-album-library-reconciliation.md) | Saved-Album Library Identity and Reconciliation | Accepted | +| [0032](0032-youtube-ask-gemini.md) | Watch-Scoped YouTube Ask Gemini | Accepted; production disabled pending profile validation | From 4ef612903c08d4cc2f13db376db55b0797f1e284 Mon Sep 17 00:00:00 2001 From: Sertac Ozercan Date: Wed, 29 Jul 2026 07:37:17 -0700 Subject: [PATCH 04/18] feat(youtube): add Ask watch panel Signed-off-by: Sertac Ozercan --- CONTEXT.md | 2 +- Sources/Kaset/KasetApp.swift | 2 +- Sources/Kaset/Resources/Localizable.xcstrings | 1414 +++++++++++++++++ .../Resources/ar.lproj/Localizable.strings | 17 + .../Resources/de.lproj/Localizable.strings | 17 + .../Resources/en.lproj/Localizable.strings | 17 + .../Resources/es.lproj/Localizable.strings | 17 + .../Resources/fr.lproj/Localizable.strings | 17 + .../Resources/id.lproj/Localizable.strings | 17 + .../Resources/it.lproj/Localizable.strings | 17 + .../Resources/ko.lproj/Localizable.strings | 17 + .../Resources/nl.lproj/Localizable.strings | 17 + .../Resources/pl.lproj/Localizable.strings | 17 + .../Resources/pt.lproj/Localizable.strings | 17 + .../Resources/ru.lproj/Localizable.strings | 17 + .../Resources/sv.lproj/Localizable.strings | 17 + .../Resources/tr.lproj/Localizable.strings | 17 + .../Resources/uk.lproj/Localizable.strings | 17 + .../Kaset/Services/API/YouTubeClient.swift | 4 +- .../YouTube/YouTubeAskViewModel.swift | 337 ++++ .../YouTube/YouTubeWatchViewModel.swift | 126 +- .../Views/YouTube/YouTubeAskPanelView.swift | 264 +++ .../Views/YouTube/YouTubeWatchView.swift | 31 +- Tests/KasetTests/AppLocalizationTests.swift | 26 + Tests/KasetTests/YouTubeAskClientTests.swift | 15 +- .../KasetTests/YouTubeAskViewModelTests.swift | 404 +++++ .../YouTubeLibraryViewModelTests.swift | 79 + docs/adr/0032-youtube-ask-gemini.md | 22 +- docs/adr/README.md | 2 +- docs/api-discovery.md | 1 + docs/architecture.md | 40 +- docs/testing.md | 108 +- docs/youtube.md | 60 + 33 files changed, 3139 insertions(+), 53 deletions(-) create mode 100644 Sources/Kaset/ViewModels/YouTube/YouTubeAskViewModel.swift create mode 100644 Sources/Kaset/Views/YouTube/YouTubeAskPanelView.swift create mode 100644 Tests/KasetTests/YouTubeAskViewModelTests.swift diff --git a/CONTEXT.md b/CONTEXT.md index 7b83063b9..289d5d551 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -54,7 +54,7 @@ The workflows that turn playlist browse data into native playback queues. This i ## YouTube Ask -The watch-page Ask Gemini capability in the regular YouTube experience. Kaset's first version is limited to server-issued suggestion chips and follow-up chips. It uses YouTube APIs rather than the playback WebView, and it remains disabled unless a read-only request-profile check proves an eligible signed-in primary-account flow. +The watch-page Ask Gemini capability in the regular YouTube experience. Kaset's first version is limited to server-issued suggestion chips and follow-up chips. It uses YouTube APIs rather than the playback WebView, selects the fixed WEB request profile explicitly in production, and appears only when YouTube returns an eligible bootstrap for a signed-in primary account. ## Ask Bootstrap diff --git a/Sources/Kaset/KasetApp.swift b/Sources/Kaset/KasetApp.swift index 74596b908..21208d878 100644 --- a/Sources/Kaset/KasetApp.swift +++ b/Sources/Kaset/KasetApp.swift @@ -142,7 +142,7 @@ struct KasetApp: App { } // YouTube (video) client — same login, www.youtube.com origin - let realYouTubeClient = YouTubeClient(authService: auth, webKitManager: webkit) + let realYouTubeClient = YouTubeClient(authService: auth, webKitManager: webkit, askFeatureEnabled: true) realYouTubeClient.brandIdProvider = { [weak account] in account?.currentBrandId } diff --git a/Sources/Kaset/Resources/Localizable.xcstrings b/Sources/Kaset/Resources/Localizable.xcstrings index f14547481..090458196 100644 --- a/Sources/Kaset/Resources/Localizable.xcstrings +++ b/Sources/Kaset/Resources/Localizable.xcstrings @@ -47926,6 +47926,1420 @@ } } } + }, + "Ask Gemini": { + "comment": "YouTube watch-page Ask Gemini panel title", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "اسأل Gemini" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Gemini fragen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Ask Gemini" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Preguntar a Gemini" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Demander à Gemini" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Tanya Gemini" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Chiedi a Gemini" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Gemini에게 질문" + } + }, + "nl": { + "stringUnit": { + "state": "translated", + "value": "Vraag Gemini" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zapytaj Gemini" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Perguntar ao Gemini" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Спросить Gemini" + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Fråga Gemini" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Gemini'ye Sor" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Запитати Gemini" + } + } + } + }, + "Responses are generated by YouTube and may be inaccurate.": { + "comment": "Disclosure shown in the YouTube Ask Gemini panel", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يتم إنشاء الردود بواسطة YouTube وقد تكون غير دقيقة." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Antworten werden von YouTube generiert und können ungenau sein." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Responses are generated by YouTube and may be inaccurate." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Las respuestas las genera YouTube y pueden contener errores." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Les réponses sont générées par YouTube et peuvent être inexactes." + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Respons dibuat oleh YouTube dan mungkin tidak akurat." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Le risposte sono generate da YouTube e potrebbero essere inesatte." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "응답은 YouTube에서 생성되며 정확하지 않을 수 있습니다." + } + }, + "nl": { + "stringUnit": { + "state": "translated", + "value": "Antwoorden worden gegenereerd door YouTube en kunnen onnauwkeurig zijn." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Odpowiedzi są generowane przez YouTube i mogą być niedokładne." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "As respostas são geradas pelo YouTube e podem estar incorretas." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ответы создаются YouTube и могут быть неточными." + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Svaren genereras av YouTube och kan vara felaktiga." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yanıtlar YouTube tarafından oluşturulur ve hatalı olabilir." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Відповіді створює YouTube, і вони можуть бути неточними." + } + } + } + }, + "Collapse Ask Gemini": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "طي «اسأل Gemini»" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "„Gemini fragen“ einklappen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Collapse Ask Gemini" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Contraer «Preguntar a Gemini»" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Replier « Demander à Gemini »" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Ciutkan Tanya Gemini" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Comprimi «Chiedi a Gemini»" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Gemini에게 질문 접기" + } + }, + "nl": { + "stringUnit": { + "state": "translated", + "value": "Vraag Gemini inklappen" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zwiń Zapytaj Gemini" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Recolher Perguntar ao Gemini" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Свернуть «Спросить Gemini»" + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Fäll ihop Fråga Gemini" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Gemini'ye Sor bölümünü daralt" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Згорнути «Запитати Gemini»" + } + } + } + }, + "Expand Ask Gemini": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "توسيع «اسأل Gemini»" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "„Gemini fragen“ ausklappen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Expand Ask Gemini" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Expandir «Preguntar a Gemini»" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Déplier « Demander à Gemini »" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Luaskan Tanya Gemini" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Espandi «Chiedi a Gemini»" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Gemini에게 질문 펼치기" + } + }, + "nl": { + "stringUnit": { + "state": "translated", + "value": "Vraag Gemini uitklappen" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Rozwiń Zapytaj Gemini" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Expandir Perguntar ao Gemini" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Развернуть «Спросить Gemini»" + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Fäll ut Fråga Gemini" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Gemini'ye Sor bölümünü genişlet" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Розгорнути «Запитати Gemini»" + } + } + } + }, + "New Chat": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "محادثة جديدة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neuer Chat" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "New Chat" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Nueva conversación" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nouvelle conversation" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Chat Baru" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Nuova chat" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "새 채팅" + } + }, + "nl": { + "stringUnit": { + "state": "translated", + "value": "Nieuwe chat" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nowy czat" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Nova conversa" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Новый чат" + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Ny chatt" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yeni Sohbet" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Новий чат" + } + } + } + }, + "You asked: %@": { + "comment": "VoiceOver label for a user turn in the YouTube Ask Gemini transcript", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لقد سألت: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Du hast gefragt: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "You asked: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Has preguntado: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Vous avez demandé: %@" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Anda bertanya: %@" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Hai chiesto: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "질문한 내용: %@" + } + }, + "nl": { + "stringUnit": { + "state": "translated", + "value": "Je vroeg: %@" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Twoje pytanie: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Você perguntou: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ваш вопрос: %@" + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Du frågade: %@" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Siz sordunuz: %@" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Ваше запитання: %@" + } + } + } + }, + "YouTube response: %@": { + "comment": "VoiceOver label for an assistant turn in the YouTube Ask Gemini transcript", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "رد YouTube: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "YouTube-Antwort: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "YouTube response: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Respuesta de YouTube: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Réponse de YouTube: %@" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Respons YouTube: %@" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Risposta di YouTube: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "YouTube 응답: %@" + } + }, + "nl": { + "stringUnit": { + "state": "translated", + "value": "Antwoord van YouTube: %@" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Odpowiedź YouTube: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Resposta do YouTube: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ответ YouTube: %@" + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Svar från YouTube: %@" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "YouTube yanıtı: %@" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Відповідь YouTube: %@" + } + } + } + }, + "Preparing Ask Gemini…": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "جارٍ إعداد «اسأل Gemini»…" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "„Gemini fragen“ wird vorbereitet…" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Preparing Ask Gemini…" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Preparando Preguntar a Gemini…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Préparation de Demander à Gemini…" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Menyiapkan Tanya Gemini…" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Preparazione di Chiedi a Gemini…" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Gemini에게 질문 준비 중…" + } + }, + "nl": { + "stringUnit": { + "state": "translated", + "value": "Vraag Gemini voorbereiden…" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przygotowywanie Zapytaj Gemini…" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Preparando Perguntar ao Gemini…" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Подготовка «Спросить Gemini»…" + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Förbereder Fråga Gemini…" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Gemini'ye Sor hazırlanıyor…" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Підготовка «Запитати Gemini»…" + } + } + } + }, + "Sending…": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "جارٍ الإرسال…" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wird gesendet…" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Sending…" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Enviando…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Envoi…" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Mengirim…" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Invio…" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "전송 중…" + } + }, + "nl": { + "stringUnit": { + "state": "translated", + "value": "Versturen…" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wysyłanie…" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Enviando…" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отправка…" + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Skickar…" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Gönderiliyor…" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Надсилання…" + } + } + } + }, + "Sign in again to use Ask Gemini.": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "سجّل الدخول مرة أخرى لاستخدام «اسأل Gemini»." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Melde dich erneut an, um „Gemini fragen“ zu verwenden." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign in again to use Ask Gemini." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Vuelve a iniciar sesión para usar Preguntar a Gemini." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Reconnectez-vous pour utiliser Demander à Gemini." + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Masuk lagi untuk menggunakan Tanya Gemini." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Accedi di nuovo per usare Chiedi a Gemini." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Gemini에게 질문을 사용하려면 다시 로그인하세요." + } + }, + "nl": { + "stringUnit": { + "state": "translated", + "value": "Meld je opnieuw aan om Vraag Gemini te gebruiken." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zaloguj się ponownie, aby używać Zapytaj Gemini." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Entre novamente para usar Perguntar ao Gemini." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Войдите снова, чтобы использовать «Спросить Gemini»." + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Logga in igen för att använda Fråga Gemini." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Gemini'ye Sor'u kullanmak için yeniden giriş yapın." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Увійдіть знову, щоб використовувати «Запитати Gemini»." + } + } + } + }, + "Ask Gemini is temporarily rate limited. Try again later.": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تم تقييد معدل استخدام «اسأل Gemini» مؤقتًا. حاول مرة أخرى لاحقًا." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "„Gemini fragen“ ist vorübergehend aufgrund zu vieler Anfragen eingeschränkt. Versuche es später erneut." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Ask Gemini is temporarily rate limited. Try again later." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Preguntar a Gemini está limitado temporalmente. Inténtalo de nuevo más tarde." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Demander à Gemini est temporairement limité. Réessayez plus tard." + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Tanya Gemini sedang dibatasi sementara. Coba lagi nanti." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Chiedi a Gemini è temporaneamente limitato. Riprova più tardi." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Gemini에게 질문의 요청이 일시적으로 제한되었습니다. 나중에 다시 시도하세요." + } + }, + "nl": { + "stringUnit": { + "state": "translated", + "value": "Vraag Gemini is tijdelijk beperkt. Probeer het later opnieuw." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zapytaj Gemini jest tymczasowo objęte limitem żądań. Spróbuj ponownie później." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Perguntar ao Gemini está temporariamente limitado. Tente novamente mais tarde." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Для функции «Спросить Gemini» временно действует ограничение частоты запросов. Повторите попытку позже." + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Fråga Gemini är tillfälligt begränsad. Försök igen senare." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Gemini'ye Sor için istek hızı geçici olarak sınırlandı. Daha sonra tekrar deneyin." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Для «Запитати Gemini» тимчасово діє обмеження частоти запитів. Спробуйте пізніше." + } + } + } + }, + "Ask Gemini couldn’t prepare this chat.": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذّر على «اسأل Gemini» إعداد هذه المحادثة." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "„Gemini fragen“ konnte diesen Chat nicht vorbereiten." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Ask Gemini couldn’t prepare this chat." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Preguntar a Gemini no pudo preparar esta conversación." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Demander à Gemini n’a pas pu préparer cette conversation." + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Tanya Gemini tidak dapat menyiapkan chat ini." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Chiedi a Gemini non ha potuto preparare questa chat." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Gemini에게 질문 채팅을 준비하지 못했습니다." + } + }, + "nl": { + "stringUnit": { + "state": "translated", + "value": "Vraag Gemini kon deze chat niet voorbereiden." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nie udało się przygotować tego czatu w Zapytaj Gemini." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Perguntar ao Gemini não conseguiu preparar esta conversa." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось подготовить этот чат в функции «Спросить Gemini»." + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Fråga Gemini kunde inte förbereda den här chatten." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Gemini'ye Sor bu sohbeti hazırlayamadı." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Не вдалося підготувати цей чат у «Запитати Gemini»." + } + } + } + }, + "Start a new chat to continue.": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "ابدأ محادثة جديدة للمتابعة." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Starte einen neuen Chat, um fortzufahren." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Start a new chat to continue." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Inicia una nueva conversación para continuar." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Commencez une nouvelle conversation pour continuer." + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Mulai chat baru untuk melanjutkan." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Avvia una nuova chat per continuare." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "계속하려면 새 채팅을 시작하세요." + } + }, + "nl": { + "stringUnit": { + "state": "translated", + "value": "Start een nieuwe chat om door te gaan." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Rozpocznij nowy czat, aby kontynuować." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Inicie uma nova conversa para continuar." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Начните новый чат, чтобы продолжить." + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Starta en ny chatt för att fortsätta." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Devam etmek için yeni bir sohbet başlatın." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Почніть новий чат, щоб продовжити." + } + } + } + }, + "Ask Gemini response ready": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "رد «اسأل Gemini» جاهز" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Antwort von „Gemini fragen“ ist bereit" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Ask Gemini response ready" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Respuesta de Preguntar a Gemini lista" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Réponse de Demander à Gemini prête" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Respons Tanya Gemini siap" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Risposta di Chiedi a Gemini pronta" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Gemini에게 질문 응답 준비됨" + } + }, + "nl": { + "stringUnit": { + "state": "translated", + "value": "Antwoord van Vraag Gemini is klaar" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Odpowiedź Zapytaj Gemini jest gotowa" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Resposta do Perguntar ao Gemini pronta" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ответ «Спросить Gemini» готов" + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Svaret från Fråga Gemini är klart" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Gemini'ye Sor yanıtı hazır" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Відповідь «Запитати Gemini» готова" + } + } + } + }, + "New Ask Gemini chat ready": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "محادثة «اسأل Gemini» الجديدة جاهزة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neuer Chat mit „Gemini fragen“ ist bereit" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "New Ask Gemini chat ready" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Nueva conversación de Preguntar a Gemini lista" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La nouvelle conversation « Demander à Gemini » est prête" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Chat Tanya Gemini baru siap" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Nuova chat di Chiedi a Gemini pronta" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "새 Gemini 채팅이 준비되었습니다" + } + }, + "nl": { + "stringUnit": { + "state": "translated", + "value": "Nieuwe chat met Vraag Gemini is klaar" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nowy czat Zapytaj Gemini jest gotowy" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Nova conversa com o Gemini pronta" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Новый чат «Спросить Gemini» готов" + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Ny Fråga Gemini-chatt är klar" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yeni Gemini sohbeti hazır" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Новий чат «Запитати Gemini» готовий" + } + } + } } }, "version": "1.1" diff --git a/Sources/Kaset/Resources/ar.lproj/Localizable.strings b/Sources/Kaset/Resources/ar.lproj/Localizable.strings index 05bdaecf1..19cfab385 100644 --- a/Sources/Kaset/Resources/ar.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/ar.lproj/Localizable.strings @@ -543,3 +543,20 @@ "Your library is empty" = "مكتبتك فارغة"; "YouTube" = "YouTube"; "YouTube video" = "فيديو YouTube"; + +// YouTube Ask Gemini +"Ask Gemini" = "اسأل Gemini"; +"Responses are generated by YouTube and may be inaccurate." = "يتم إنشاء الردود بواسطة YouTube وقد تكون غير دقيقة."; +"Collapse Ask Gemini" = "طي «اسأل Gemini»"; +"Expand Ask Gemini" = "توسيع «اسأل Gemini»"; +"New Chat" = "محادثة جديدة"; +"You asked: %@" = "لقد سألت: %@"; +"YouTube response: %@" = "رد YouTube: %@"; +"Preparing Ask Gemini…" = "جارٍ إعداد «اسأل Gemini»…"; +"Sending…" = "جارٍ الإرسال…"; +"Sign in again to use Ask Gemini." = "سجّل الدخول مرة أخرى لاستخدام «اسأل Gemini»."; +"Ask Gemini is temporarily rate limited. Try again later." = "تم تقييد معدل استخدام «اسأل Gemini» مؤقتًا. حاول مرة أخرى لاحقًا."; +"Ask Gemini couldn’t prepare this chat." = "تعذّر على «اسأل Gemini» إعداد هذه المحادثة."; +"Start a new chat to continue." = "ابدأ محادثة جديدة للمتابعة."; +"Ask Gemini response ready" = "رد «اسأل Gemini» جاهز"; +"New Ask Gemini chat ready" = "محادثة «اسأل Gemini» الجديدة جاهزة"; diff --git a/Sources/Kaset/Resources/de.lproj/Localizable.strings b/Sources/Kaset/Resources/de.lproj/Localizable.strings index c64ca4505..8b92a0b89 100644 --- a/Sources/Kaset/Resources/de.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/de.lproj/Localizable.strings @@ -543,3 +543,20 @@ "Your library is empty" = "Deine Mediathek ist leer"; "YouTube" = "YouTube"; "YouTube video" = "YouTube-Video"; + +// YouTube Ask Gemini +"Ask Gemini" = "Gemini fragen"; +"Responses are generated by YouTube and may be inaccurate." = "Die Antworten werden von YouTube generiert und können ungenau sein."; +"Collapse Ask Gemini" = "„Gemini fragen“ einklappen"; +"Expand Ask Gemini" = "„Gemini fragen“ ausklappen"; +"New Chat" = "Neuer Chat"; +"You asked: %@" = "Du hast gefragt: %@"; +"YouTube response: %@" = "YouTube-Antwort: %@"; +"Preparing Ask Gemini…" = "„Gemini fragen“ wird vorbereitet…"; +"Sending…" = "Wird gesendet…"; +"Sign in again to use Ask Gemini." = "Melde dich erneut an, um „Gemini fragen“ zu verwenden."; +"Ask Gemini is temporarily rate limited. Try again later." = "„Gemini fragen“ ist vorübergehend aufgrund zu vieler Anfragen eingeschränkt. Versuche es später erneut."; +"Ask Gemini couldn’t prepare this chat." = "„Gemini fragen“ konnte diesen Chat nicht vorbereiten."; +"Start a new chat to continue." = "Starte einen neuen Chat, um fortzufahren."; +"Ask Gemini response ready" = "Antwort von „Gemini fragen“ ist bereit"; +"New Ask Gemini chat ready" = "Neuer Chat mit „Gemini fragen“ ist bereit"; diff --git a/Sources/Kaset/Resources/en.lproj/Localizable.strings b/Sources/Kaset/Resources/en.lproj/Localizable.strings index f4f0f84b9..2e666d57c 100644 --- a/Sources/Kaset/Resources/en.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/en.lproj/Localizable.strings @@ -14,3 +14,20 @@ "Kaset could not remove saved sign-in data. Try signing out again before quitting." = "Kaset could not remove saved sign-in data. Try signing out again before quitting."; "Sign-In Cleanup Required" = "Sign-In Cleanup Required"; "Kaset could not safely clear saved sign-in data. Retry before signing in again." = "Kaset could not safely clear saved sign-in data. Retry before signing in again."; + +// YouTube Ask Gemini +"Ask Gemini" = "Ask Gemini"; +"Responses are generated by YouTube and may be inaccurate." = "Responses are generated by YouTube and may be inaccurate."; +"Collapse Ask Gemini" = "Collapse Ask Gemini"; +"Expand Ask Gemini" = "Expand Ask Gemini"; +"New Chat" = "New Chat"; +"You asked: %@" = "You asked: %@"; +"YouTube response: %@" = "YouTube response: %@"; +"Preparing Ask Gemini…" = "Preparing Ask Gemini…"; +"Sending…" = "Sending…"; +"Sign in again to use Ask Gemini." = "Sign in again to use Ask Gemini."; +"Ask Gemini is temporarily rate limited. Try again later." = "Ask Gemini is temporarily rate limited. Try again later."; +"Ask Gemini couldn’t prepare this chat." = "Ask Gemini couldn’t prepare this chat."; +"Start a new chat to continue." = "Start a new chat to continue."; +"Ask Gemini response ready" = "Ask Gemini response ready"; +"New Ask Gemini chat ready" = "New Ask Gemini chat ready"; diff --git a/Sources/Kaset/Resources/es.lproj/Localizable.strings b/Sources/Kaset/Resources/es.lproj/Localizable.strings index d7e13d793..2dff09961 100644 --- a/Sources/Kaset/Resources/es.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/es.lproj/Localizable.strings @@ -543,3 +543,20 @@ "Your library is empty" = "Tu biblioteca está vacía"; "YouTube" = "YouTube"; "YouTube video" = "Video de YouTube"; + +// YouTube Ask Gemini +"Ask Gemini" = "Preguntar a Gemini"; +"Responses are generated by YouTube and may be inaccurate." = "Las respuestas las genera YouTube y pueden contener errores."; +"Collapse Ask Gemini" = "Contraer «Preguntar a Gemini»"; +"Expand Ask Gemini" = "Expandir «Preguntar a Gemini»"; +"New Chat" = "Nueva conversación"; +"You asked: %@" = "Has preguntado: %@"; +"YouTube response: %@" = "Respuesta de YouTube: %@"; +"Preparing Ask Gemini…" = "Preparando Preguntar a Gemini…"; +"Sending…" = "Enviando…"; +"Sign in again to use Ask Gemini." = "Vuelve a iniciar sesión para usar Preguntar a Gemini."; +"Ask Gemini is temporarily rate limited. Try again later." = "Preguntar a Gemini está limitado temporalmente. Inténtalo de nuevo más tarde."; +"Ask Gemini couldn’t prepare this chat." = "Preguntar a Gemini no pudo preparar esta conversación."; +"Start a new chat to continue." = "Inicia una nueva conversación para continuar."; +"Ask Gemini response ready" = "Respuesta de Preguntar a Gemini lista"; +"New Ask Gemini chat ready" = "Nueva conversación de Preguntar a Gemini lista"; diff --git a/Sources/Kaset/Resources/fr.lproj/Localizable.strings b/Sources/Kaset/Resources/fr.lproj/Localizable.strings index 612f04e4b..1c3c6f455 100644 --- a/Sources/Kaset/Resources/fr.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/fr.lproj/Localizable.strings @@ -543,3 +543,20 @@ "Your library is empty" = "Votre bibliothèque est vide"; "YouTube" = "YouTube"; "YouTube video" = "Vidéo YouTube"; + +// YouTube Ask Gemini +"Ask Gemini" = "Demander à Gemini"; +"Responses are generated by YouTube and may be inaccurate." = "Les réponses sont générées par YouTube et peuvent être inexactes."; +"Collapse Ask Gemini" = "Replier « Demander à Gemini »"; +"Expand Ask Gemini" = "Déplier « Demander à Gemini »"; +"New Chat" = "Nouvelle conversation"; +"You asked: %@" = "Vous avez demandé: %@"; +"YouTube response: %@" = "Réponse de YouTube: %@"; +"Preparing Ask Gemini…" = "Préparation de Demander à Gemini…"; +"Sending…" = "Envoi…"; +"Sign in again to use Ask Gemini." = "Reconnectez-vous pour utiliser Demander à Gemini."; +"Ask Gemini is temporarily rate limited. Try again later." = "Demander à Gemini est temporairement limité. Réessayez plus tard."; +"Ask Gemini couldn’t prepare this chat." = "Demander à Gemini n’a pas pu préparer cette conversation."; +"Start a new chat to continue." = "Commencez une nouvelle conversation pour continuer."; +"Ask Gemini response ready" = "Réponse de Demander à Gemini prête"; +"New Ask Gemini chat ready" = "La nouvelle conversation « Demander à Gemini » est prête"; diff --git a/Sources/Kaset/Resources/id.lproj/Localizable.strings b/Sources/Kaset/Resources/id.lproj/Localizable.strings index 62ab3d827..ef2c188af 100644 --- a/Sources/Kaset/Resources/id.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/id.lproj/Localizable.strings @@ -543,3 +543,20 @@ "Your library is empty" = "Pustaka Anda kosong"; "YouTube" = "YouTube"; "YouTube video" = "Video YouTube"; + +// YouTube Ask Gemini +"Ask Gemini" = "Tanya Gemini"; +"Responses are generated by YouTube and may be inaccurate." = "Respons dibuat oleh YouTube dan mungkin tidak akurat."; +"Collapse Ask Gemini" = "Ciutkan Tanya Gemini"; +"Expand Ask Gemini" = "Luaskan Tanya Gemini"; +"New Chat" = "Chat Baru"; +"You asked: %@" = "Anda bertanya: %@"; +"YouTube response: %@" = "Respons YouTube: %@"; +"Preparing Ask Gemini…" = "Menyiapkan Tanya Gemini…"; +"Sending…" = "Mengirim…"; +"Sign in again to use Ask Gemini." = "Masuk lagi untuk menggunakan Tanya Gemini."; +"Ask Gemini is temporarily rate limited. Try again later." = "Tanya Gemini sedang dibatasi sementara. Coba lagi nanti."; +"Ask Gemini couldn’t prepare this chat." = "Tanya Gemini tidak dapat menyiapkan chat ini."; +"Start a new chat to continue." = "Mulai chat baru untuk melanjutkan."; +"Ask Gemini response ready" = "Respons Tanya Gemini siap"; +"New Ask Gemini chat ready" = "Chat Tanya Gemini baru siap"; diff --git a/Sources/Kaset/Resources/it.lproj/Localizable.strings b/Sources/Kaset/Resources/it.lproj/Localizable.strings index c8e39c87a..b81ea5ee0 100644 --- a/Sources/Kaset/Resources/it.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/it.lproj/Localizable.strings @@ -543,3 +543,20 @@ "Your library is empty" = "La tua libreria è vuota"; "YouTube" = "YouTube"; "YouTube video" = "Video di YouTube"; + +// YouTube Ask Gemini +"Ask Gemini" = "Chiedi a Gemini"; +"Responses are generated by YouTube and may be inaccurate." = "Le risposte sono generate da YouTube e potrebbero essere inesatte."; +"Collapse Ask Gemini" = "Comprimi «Chiedi a Gemini»"; +"Expand Ask Gemini" = "Espandi «Chiedi a Gemini»"; +"New Chat" = "Nuova chat"; +"You asked: %@" = "Hai chiesto: %@"; +"YouTube response: %@" = "Risposta di YouTube: %@"; +"Preparing Ask Gemini…" = "Preparazione di Chiedi a Gemini…"; +"Sending…" = "Invio…"; +"Sign in again to use Ask Gemini." = "Accedi di nuovo per usare Chiedi a Gemini."; +"Ask Gemini is temporarily rate limited. Try again later." = "Chiedi a Gemini è temporaneamente limitato. Riprova più tardi."; +"Ask Gemini couldn’t prepare this chat." = "Chiedi a Gemini non ha potuto preparare questa chat."; +"Start a new chat to continue." = "Avvia una nuova chat per continuare."; +"Ask Gemini response ready" = "Risposta di Chiedi a Gemini pronta"; +"New Ask Gemini chat ready" = "Nuova chat di Chiedi a Gemini pronta"; diff --git a/Sources/Kaset/Resources/ko.lproj/Localizable.strings b/Sources/Kaset/Resources/ko.lproj/Localizable.strings index f6c2ba792..1f8910097 100644 --- a/Sources/Kaset/Resources/ko.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/ko.lproj/Localizable.strings @@ -543,3 +543,20 @@ "Your library is empty" = "보관함이 비어 있습니다"; "YouTube" = "YouTube"; "YouTube video" = "YouTube 동영상"; + +// YouTube Ask Gemini +"Ask Gemini" = "Gemini에게 질문"; +"Responses are generated by YouTube and may be inaccurate." = "응답은 YouTube에서 생성되며 정확하지 않을 수 있습니다."; +"Collapse Ask Gemini" = "Gemini에게 질문 접기"; +"Expand Ask Gemini" = "Gemini에게 질문 펼치기"; +"New Chat" = "새 채팅"; +"You asked: %@" = "질문한 내용: %@"; +"YouTube response: %@" = "YouTube 응답: %@"; +"Preparing Ask Gemini…" = "Gemini에게 질문 준비 중…"; +"Sending…" = "전송 중…"; +"Sign in again to use Ask Gemini." = "Gemini에게 질문을 사용하려면 다시 로그인하세요."; +"Ask Gemini is temporarily rate limited. Try again later." = "Gemini에게 질문의 요청이 일시적으로 제한되었습니다. 나중에 다시 시도하세요."; +"Ask Gemini couldn’t prepare this chat." = "Gemini에게 질문 채팅을 준비하지 못했습니다."; +"Start a new chat to continue." = "계속하려면 새 채팅을 시작하세요."; +"Ask Gemini response ready" = "Gemini에게 질문 응답 준비됨"; +"New Ask Gemini chat ready" = "새 Gemini 채팅이 준비되었습니다"; diff --git a/Sources/Kaset/Resources/nl.lproj/Localizable.strings b/Sources/Kaset/Resources/nl.lproj/Localizable.strings index 7c0cebdb6..e53f03f47 100644 --- a/Sources/Kaset/Resources/nl.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/nl.lproj/Localizable.strings @@ -543,3 +543,20 @@ "Your library is empty" = "Je bibliotheek is leeg"; "YouTube" = "YouTube"; "YouTube video" = "YouTube-video"; + +// YouTube Ask Gemini +"Ask Gemini" = "Vraag Gemini"; +"Responses are generated by YouTube and may be inaccurate." = "Antwoorden worden gegenereerd door YouTube en kunnen onnauwkeurig zijn."; +"Collapse Ask Gemini" = "Vraag Gemini inklappen"; +"Expand Ask Gemini" = "Vraag Gemini uitklappen"; +"New Chat" = "Nieuwe chat"; +"You asked: %@" = "Je vroeg: %@"; +"YouTube response: %@" = "Antwoord van YouTube: %@"; +"Preparing Ask Gemini…" = "Vraag Gemini voorbereiden…"; +"Sending…" = "Versturen…"; +"Sign in again to use Ask Gemini." = "Meld je opnieuw aan om Vraag Gemini te gebruiken."; +"Ask Gemini is temporarily rate limited. Try again later." = "Vraag Gemini is tijdelijk beperkt. Probeer het later opnieuw."; +"Ask Gemini couldn’t prepare this chat." = "Vraag Gemini kon deze chat niet voorbereiden."; +"Start a new chat to continue." = "Start een nieuwe chat om door te gaan."; +"Ask Gemini response ready" = "Antwoord van Vraag Gemini is klaar"; +"New Ask Gemini chat ready" = "Nieuwe chat met Vraag Gemini is klaar"; diff --git a/Sources/Kaset/Resources/pl.lproj/Localizable.strings b/Sources/Kaset/Resources/pl.lproj/Localizable.strings index adfc3040b..956f80523 100644 --- a/Sources/Kaset/Resources/pl.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/pl.lproj/Localizable.strings @@ -543,3 +543,20 @@ "Your library is empty" = "Twoja biblioteka jest pusta"; "YouTube" = "YouTube"; "YouTube video" = "Film z YouTube"; + +// YouTube Ask Gemini +"Ask Gemini" = "Zapytaj Gemini"; +"Responses are generated by YouTube and may be inaccurate." = "Odpowiedzi są generowane przez YouTube i mogą być niedokładne."; +"Collapse Ask Gemini" = "Zwiń Zapytaj Gemini"; +"Expand Ask Gemini" = "Rozwiń Zapytaj Gemini"; +"New Chat" = "Nowy czat"; +"You asked: %@" = "Twoje pytanie: %@"; +"YouTube response: %@" = "Odpowiedź YouTube: %@"; +"Preparing Ask Gemini…" = "Przygotowywanie Zapytaj Gemini…"; +"Sending…" = "Wysyłanie…"; +"Sign in again to use Ask Gemini." = "Zaloguj się ponownie, aby używać Zapytaj Gemini."; +"Ask Gemini is temporarily rate limited. Try again later." = "Zapytaj Gemini jest tymczasowo objęte limitem żądań. Spróbuj ponownie później."; +"Ask Gemini couldn’t prepare this chat." = "Nie udało się przygotować tego czatu w Zapytaj Gemini."; +"Start a new chat to continue." = "Rozpocznij nowy czat, aby kontynuować."; +"Ask Gemini response ready" = "Odpowiedź Zapytaj Gemini jest gotowa"; +"New Ask Gemini chat ready" = "Nowy czat Zapytaj Gemini jest gotowy"; diff --git a/Sources/Kaset/Resources/pt.lproj/Localizable.strings b/Sources/Kaset/Resources/pt.lproj/Localizable.strings index 5b767decf..1bea364a2 100644 --- a/Sources/Kaset/Resources/pt.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/pt.lproj/Localizable.strings @@ -543,3 +543,20 @@ "Your library is empty" = "A sua biblioteca está vazia"; "YouTube" = "YouTube"; "YouTube video" = "Vídeo do YouTube"; + +// YouTube Ask Gemini +"Ask Gemini" = "Perguntar ao Gemini"; +"Responses are generated by YouTube and may be inaccurate." = "As respostas são geradas pelo YouTube e podem estar incorretas."; +"Collapse Ask Gemini" = "Recolher Perguntar ao Gemini"; +"Expand Ask Gemini" = "Expandir Perguntar ao Gemini"; +"New Chat" = "Nova conversa"; +"You asked: %@" = "Você perguntou: %@"; +"YouTube response: %@" = "Resposta do YouTube: %@"; +"Preparing Ask Gemini…" = "Preparando Perguntar ao Gemini…"; +"Sending…" = "Enviando…"; +"Sign in again to use Ask Gemini." = "Entre novamente para usar Perguntar ao Gemini."; +"Ask Gemini is temporarily rate limited. Try again later." = "Perguntar ao Gemini está temporariamente limitado. Tente novamente mais tarde."; +"Ask Gemini couldn’t prepare this chat." = "Perguntar ao Gemini não conseguiu preparar esta conversa."; +"Start a new chat to continue." = "Inicie uma nova conversa para continuar."; +"Ask Gemini response ready" = "Resposta do Perguntar ao Gemini pronta"; +"New Ask Gemini chat ready" = "Nova conversa com o Gemini pronta"; diff --git a/Sources/Kaset/Resources/ru.lproj/Localizable.strings b/Sources/Kaset/Resources/ru.lproj/Localizable.strings index edad6036a..71ba40413 100644 --- a/Sources/Kaset/Resources/ru.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/ru.lproj/Localizable.strings @@ -543,3 +543,20 @@ "Your library is empty" = "Ваша медиатека пуста"; "YouTube" = "YouTube"; "YouTube video" = "Видео YouTube"; + +// YouTube Ask Gemini +"Ask Gemini" = "Спросить Gemini"; +"Responses are generated by YouTube and may be inaccurate." = "Ответы создаются YouTube и могут быть неточными."; +"Collapse Ask Gemini" = "Свернуть «Спросить Gemini»"; +"Expand Ask Gemini" = "Развернуть «Спросить Gemini»"; +"New Chat" = "Новый чат"; +"You asked: %@" = "Ваш вопрос: %@"; +"YouTube response: %@" = "Ответ YouTube: %@"; +"Preparing Ask Gemini…" = "Подготовка «Спросить Gemini»…"; +"Sending…" = "Отправка…"; +"Sign in again to use Ask Gemini." = "Войдите снова, чтобы использовать «Спросить Gemini»."; +"Ask Gemini is temporarily rate limited. Try again later." = "Для функции «Спросить Gemini» временно действует ограничение частоты запросов. Повторите попытку позже."; +"Ask Gemini couldn’t prepare this chat." = "Не удалось подготовить этот чат в функции «Спросить Gemini»."; +"Start a new chat to continue." = "Начните новый чат, чтобы продолжить."; +"Ask Gemini response ready" = "Ответ «Спросить Gemini» готов"; +"New Ask Gemini chat ready" = "Новый чат «Спросить Gemini» готов"; diff --git a/Sources/Kaset/Resources/sv.lproj/Localizable.strings b/Sources/Kaset/Resources/sv.lproj/Localizable.strings index 1b39f897c..91d807de1 100644 --- a/Sources/Kaset/Resources/sv.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/sv.lproj/Localizable.strings @@ -543,3 +543,20 @@ "Your library is empty" = "Ditt bibliotek är tomt"; "YouTube" = "YouTube"; "YouTube video" = "YouTube-video"; + +// YouTube Ask Gemini +"Ask Gemini" = "Fråga Gemini"; +"Responses are generated by YouTube and may be inaccurate." = "Svaren genereras av YouTube och kan vara felaktiga."; +"Collapse Ask Gemini" = "Fäll ihop Fråga Gemini"; +"Expand Ask Gemini" = "Fäll ut Fråga Gemini"; +"New Chat" = "Ny chatt"; +"You asked: %@" = "Du frågade: %@"; +"YouTube response: %@" = "Svar från YouTube: %@"; +"Preparing Ask Gemini…" = "Förbereder Fråga Gemini…"; +"Sending…" = "Skickar…"; +"Sign in again to use Ask Gemini." = "Logga in igen för att använda Fråga Gemini."; +"Ask Gemini is temporarily rate limited. Try again later." = "Fråga Gemini är tillfälligt begränsad. Försök igen senare."; +"Ask Gemini couldn’t prepare this chat." = "Fråga Gemini kunde inte förbereda den här chatten."; +"Start a new chat to continue." = "Starta en ny chatt för att fortsätta."; +"Ask Gemini response ready" = "Svaret från Fråga Gemini är klart"; +"New Ask Gemini chat ready" = "Ny Fråga Gemini-chatt är klar"; diff --git a/Sources/Kaset/Resources/tr.lproj/Localizable.strings b/Sources/Kaset/Resources/tr.lproj/Localizable.strings index f94647d52..44d226d43 100644 --- a/Sources/Kaset/Resources/tr.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/tr.lproj/Localizable.strings @@ -543,3 +543,20 @@ "Your library is empty" = "Kitaplığınız boş"; "YouTube" = "YouTube"; "YouTube video" = "YouTube videosu"; + +// YouTube Ask Gemini +"Ask Gemini" = "Gemini'ye Sor"; +"Responses are generated by YouTube and may be inaccurate." = "Yanıtlar YouTube tarafından oluşturulur ve hatalı olabilir."; +"Collapse Ask Gemini" = "Gemini'ye Sor bölümünü daralt"; +"Expand Ask Gemini" = "Gemini'ye Sor bölümünü genişlet"; +"New Chat" = "Yeni Sohbet"; +"You asked: %@" = "Siz sordunuz: %@"; +"YouTube response: %@" = "YouTube yanıtı: %@"; +"Preparing Ask Gemini…" = "Gemini'ye Sor hazırlanıyor…"; +"Sending…" = "Gönderiliyor…"; +"Sign in again to use Ask Gemini." = "Gemini'ye Sor'u kullanmak için yeniden giriş yapın."; +"Ask Gemini is temporarily rate limited. Try again later." = "Gemini'ye Sor için istek hızı geçici olarak sınırlandı. Daha sonra tekrar deneyin."; +"Ask Gemini couldn’t prepare this chat." = "Gemini'ye Sor bu sohbeti hazırlayamadı."; +"Start a new chat to continue." = "Devam etmek için yeni bir sohbet başlatın."; +"Ask Gemini response ready" = "Gemini'ye Sor yanıtı hazır"; +"New Ask Gemini chat ready" = "Yeni Gemini sohbeti hazır"; diff --git a/Sources/Kaset/Resources/uk.lproj/Localizable.strings b/Sources/Kaset/Resources/uk.lproj/Localizable.strings index 55a721a50..c08426641 100644 --- a/Sources/Kaset/Resources/uk.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/uk.lproj/Localizable.strings @@ -543,3 +543,20 @@ "Your library is empty" = "Ваша бібліотека порожня"; "YouTube" = "YouTube"; "YouTube video" = "Відео YouTube"; + +// YouTube Ask Gemini +"Ask Gemini" = "Запитати Gemini"; +"Responses are generated by YouTube and may be inaccurate." = "Відповіді створює YouTube, і вони можуть бути неточними."; +"Collapse Ask Gemini" = "Згорнути «Запитати Gemini»"; +"Expand Ask Gemini" = "Розгорнути «Запитати Gemini»"; +"New Chat" = "Новий чат"; +"You asked: %@" = "Ваше запитання: %@"; +"YouTube response: %@" = "Відповідь YouTube: %@"; +"Preparing Ask Gemini…" = "Підготовка «Запитати Gemini»…"; +"Sending…" = "Надсилання…"; +"Sign in again to use Ask Gemini." = "Увійдіть знову, щоб використовувати «Запитати Gemini»."; +"Ask Gemini is temporarily rate limited. Try again later." = "Для «Запитати Gemini» тимчасово діє обмеження частоти запитів. Спробуйте пізніше."; +"Ask Gemini couldn’t prepare this chat." = "Не вдалося підготувати цей чат у «Запитати Gemini»."; +"Start a new chat to continue." = "Почніть новий чат, щоб продовжити."; +"Ask Gemini response ready" = "Відповідь «Запитати Gemini» готова"; +"New Ask Gemini chat ready" = "Новий чат «Запитати Gemini» готовий"; diff --git a/Sources/Kaset/Services/API/YouTubeClient.swift b/Sources/Kaset/Services/API/YouTubeClient.swift index 67c4c7e31..7604cf14c 100644 --- a/Sources/Kaset/Services/API/YouTubeClient.swift +++ b/Sources/Kaset/Services/API/YouTubeClient.swift @@ -90,8 +90,8 @@ final class YouTubeClient: YouTubeClientProtocol { // swiftlint:disable:this typ self.session = resolvedSession self.askTransport = YouTubeAskTransport(configuration: resolvedSession.configuration) self.askMessageIDGenerator = askMessageIDGenerator ?? YouTubeAskMessageIDGenerator() - // The July 28, 2026 read-only parity run did not establish a passing - // request profile. Production remains fail-closed; tests opt in explicitly. + // Ask remains an explicit construction-time capability. The production + // app selects the fixed WEB profile; isolated clients and tests default off. self.askRequestProfile = askFeatureEnabled ? .fixedProduction : nil } diff --git a/Sources/Kaset/ViewModels/YouTube/YouTubeAskViewModel.swift b/Sources/Kaset/ViewModels/YouTube/YouTubeAskViewModel.swift new file mode 100644 index 000000000..5124562b6 --- /dev/null +++ b/Sources/Kaset/ViewModels/YouTube/YouTubeAskViewModel.swift @@ -0,0 +1,337 @@ +import Foundation +import Observation + +/// Owns one watch-scoped Ask Gemini conversation and every task that can mutate it. +@MainActor +@Observable +final class YouTubeAskViewModel { + enum Activity: Equatable { + case idle + case preparing + case sending + } + + enum AccessibilityAnnouncement: Equatable { + case responseReady + case newChatReady + } + + let videoID: String + let client: any YouTubeClientProtocol + + private(set) var isAvailable = false + private(set) var isExpanded = false + private(set) var activity: Activity = .idle + private(set) var conversation: YouTubeAskConversation? + private(set) var presentationError: YouTubeAskPresentationError? + private(set) var requiresNewChat = false + private(set) var accessibilityAnnouncement: AccessibilityAnnouncement? + private(set) var accessibilityAnnouncementSequence = 0 + + private var bootstrap: YouTubeAskBootstrap? + private var operationGeneration: UInt64 = 0 + @ObservationIgnored private var requestTask: Task? + + init(videoID: String, client: any YouTubeClientProtocol) { + self.videoID = videoID + self.client = client + } + + deinit { + self.requestTask?.cancel() + } + + var messages: [YouTubeAskMessage] { + self.conversation?.messages ?? [] + } + + var suggestions: [YouTubeAskSuggestion] { + self.conversation?.suggestions ?? [] + } + + var isBusy: Bool { + self.activity != .idle + } + + var hasStarted: Bool { + self.conversation?.hasStarted == true + } + + var canStartNewChat: Bool { + self.isAvailable && (self.hasStarted || self.requiresNewChat) + } + + /// Replaces all prior state with a fresh watch-page bootstrap. It remains + /// collapsed and does not materialize the panel until the user expands it. + func seed(_ bootstrap: YouTubeAskBootstrap?) { + self.cancelCurrentOperation() + self.bootstrap = bootstrap + self.conversation = nil + self.isAvailable = bootstrap != nil + self.isExpanded = false + self.activity = .idle + self.presentationError = nil + self.requiresNewChat = false + self.accessibilityAnnouncement = nil + } + + func toggleExpanded() { + self.setExpanded(!self.isExpanded) + } + + func setExpanded(_ expanded: Bool) { + guard self.isAvailable else { return } + self.isExpanded = expanded + if expanded { + self.prepareInitialConversationIfNeeded() + } + } + + func selectSuggestion(id suggestionID: YouTubeAskSuggestion.ID) { + guard !self.isBusy, + !self.requiresNewChat, + let conversation = self.conversation, + let pendingConversation = conversation.appendingUserTurn(for: suggestionID) + else { + return + } + + self.presentationError = nil + self.conversation = pendingConversation + let generation = self.beginOperation(.sending) + let client = self.client + + self.requestTask = Task { @MainActor [weak self, client, pendingConversation] in + do { + let nextConversation = try await client.continueAskConversation( + pendingConversation, + selecting: suggestionID + ) + guard let self, self.operationGeneration == generation else { return } + self.conversation = nextConversation + self.requiresNewChat = false + self.presentationError = nil + self.finishOperation(generation: generation) + self.publishAnnouncement(.responseReady) + } catch is CancellationError { + guard let self, self.operationGeneration == generation else { return } + if Task.isCancelled { + self.finishOperation(generation: generation) + return + } + self.failSubmission( + pendingConversation: pendingConversation, + error: YouTubeAskClientError.sessionChanged, + generation: generation + ) + } catch { + guard let self, self.operationGeneration == generation else { return } + self.failSubmission( + pendingConversation: pendingConversation, + error: error, + generation: generation + ) + } + } + } + + /// Prepares a fresh bootstrap and conversation transactionally. A usable + /// current conversation stays visible unless the new one is fully ready. + func startNewChat() { + guard self.canStartNewChat, !self.isBusy else { return } + + self.presentationError = nil + let generation = self.beginOperation(.preparing) + let client = self.client + let videoID = self.videoID + + self.requestTask = Task { @MainActor [weak self, client, videoID] in + do { + let page = try await client.getWatchPage(videoId: videoID) + guard let bootstrap = page.askBootstrap else { + throw YouTubeAskClientError.unavailable + } + let newConversation = try await client.loadAskConversation(from: bootstrap) + guard let self, self.operationGeneration == generation else { return } + + self.bootstrap = nil + self.conversation = newConversation + self.isAvailable = true + self.isExpanded = true + self.requiresNewChat = false + self.presentationError = nil + self.finishOperation(generation: generation) + self.publishAnnouncement(.newChatReady) + } catch is CancellationError { + guard let self, self.operationGeneration == generation else { return } + if Task.isCancelled { + self.finishOperation(generation: generation) + return + } + self.failNewChat( + error: YouTubeAskClientError.sessionChanged, + generation: generation + ) + } catch { + guard let self, self.operationGeneration == generation else { return } + self.failNewChat(error: error, generation: generation) + } + } + } + + /// Cancels in-flight work and removes all visible and opaque conversation + /// state. Called when the watch route or its account/auth scope goes away. + func cancelAndDiscard() { + self.cancelCurrentOperation() + self.bootstrap = nil + self.conversation = nil + self.isAvailable = false + self.isExpanded = false + self.activity = .idle + self.presentationError = nil + self.requiresNewChat = false + self.accessibilityAnnouncement = nil + } + + private func prepareInitialConversationIfNeeded() { + guard self.conversation == nil, + !self.isBusy, + let bootstrap = self.bootstrap + else { + return + } + + self.presentationError = nil + let generation = self.beginOperation(.preparing) + let client = self.client + + self.requestTask = Task { @MainActor [weak self, client, bootstrap] in + do { + let conversation = try await client.loadAskConversation(from: bootstrap) + guard let self, self.operationGeneration == generation else { return } + self.bootstrap = nil + self.conversation = conversation + self.requiresNewChat = false + self.presentationError = nil + self.finishOperation(generation: generation) + } catch is CancellationError { + guard let self, self.operationGeneration == generation else { return } + if Task.isCancelled { + self.finishOperation(generation: generation) + return + } + self.failInitialPreparation( + error: YouTubeAskClientError.sessionChanged, + generation: generation + ) + } catch { + guard let self, self.operationGeneration == generation else { return } + self.failInitialPreparation(error: error, generation: generation) + } + } + } + + private func failInitialPreparation(error: any Error, generation: UInt64) { + self.bootstrap = nil + self.conversation = nil + self.requiresNewChat = true + self.presentationError = Self.presentationError(for: error, duringPreparation: true) + self.finishOperation(generation: generation) + } + + private func failSubmission( + pendingConversation: YouTubeAskConversation, + error: any Error, + generation: UInt64 + ) { + self.bootstrap = nil + self.conversation = pendingConversation.discardingOpaqueState() + self.requiresNewChat = true + self.presentationError = Self.presentationError(for: error, duringPreparation: false) + self.finishOperation(generation: generation) + } + + private func failNewChat(error: any Error, generation: UInt64) { + if Self.invalidatesExistingSession(error) { + self.bootstrap = nil + self.conversation = self.conversation?.discardingOpaqueState() + self.requiresNewChat = true + } + self.presentationError = Self.presentationError(for: error, duringPreparation: true) + self.finishOperation(generation: generation) + } + + private func beginOperation(_ activity: Activity) -> UInt64 { + self.operationGeneration &+= 1 + self.activity = activity + return self.operationGeneration + } + + private func finishOperation(generation: UInt64) { + guard self.operationGeneration == generation else { return } + self.requestTask = nil + self.activity = .idle + } + + private func cancelCurrentOperation() { + self.operationGeneration &+= 1 + self.requestTask?.cancel() + self.requestTask = nil + } + + private func publishAnnouncement(_ announcement: AccessibilityAnnouncement) { + self.accessibilityAnnouncement = announcement + self.accessibilityAnnouncementSequence &+= 1 + } + + private static func presentationError( + for error: any Error, + duringPreparation: Bool + ) -> YouTubeAskPresentationError { + if let askError = error as? YouTubeAskClientError { + switch askError { + case .authenticationRequired: + return .authentication + case .rateLimited: + return .rateLimited + case .sessionChanged: + return duringPreparation ? .preparation : .restartRequired + case .responseTooLarge, .invalidResponse, .unavailable: + return duringPreparation ? .preparation : .restartRequired + } + } + + if let apiError = error as? YTMusicError { + switch apiError { + case .authExpired, .notAuthenticated: + return .authentication + default: + return duringPreparation ? .preparation : .restartRequired + } + } + + return duringPreparation ? .preparation : .restartRequired + } + + private static func invalidatesExistingSession(_ error: any Error) -> Bool { + if let askError = error as? YouTubeAskClientError { + switch askError { + case .authenticationRequired, .sessionChanged: + return true + case .rateLimited, .responseTooLarge, .invalidResponse, .unavailable: + return false + } + } + + if let apiError = error as? YTMusicError { + switch apiError { + case .authExpired, .notAuthenticated: + return true + default: + return false + } + } + + return false + } +} diff --git a/Sources/Kaset/ViewModels/YouTube/YouTubeWatchViewModel.swift b/Sources/Kaset/ViewModels/YouTube/YouTubeWatchViewModel.swift index 178c06e00..54ae6278b 100644 --- a/Sources/Kaset/ViewModels/YouTube/YouTubeWatchViewModel.swift +++ b/Sources/Kaset/ViewModels/YouTube/YouTubeWatchViewModel.swift @@ -1,6 +1,36 @@ import Foundation import Observation +// MARK: - YouTubeAskAccountScopeObservation + +/// In-memory observation key for watch-page account changes. The raw scope is +/// never persisted or rendered; it only restarts the Ask bootstrap request. +struct YouTubeAskAccountScopeObservation: Hashable, Sendable { + let authenticationGeneration: UInt64 + let hasPersonalAccount: Bool + let accountScopeID: String? + let isPrimaryAccount: Bool? + let verifiedIdentitySequence: Int +} + +// MARK: CustomStringConvertible, CustomDebugStringConvertible, CustomReflectable + +extension YouTubeAskAccountScopeObservation: CustomStringConvertible, CustomDebugStringConvertible, CustomReflectable { + var description: String { + "" + } + + var debugDescription: String { + self.description + } + + var customMirror: Mirror { + Mirror(reflecting: self.description) + } +} + +// MARK: - YouTubeWatchViewModel + /// View model for the YouTube watch page (metadata + related videos). @MainActor @Observable @@ -11,10 +41,15 @@ final class YouTubeWatchViewModel { /// Watch-page companion data. private(set) var data: WatchNextData = .empty + /// Watch-scoped Ask Gemini state. The child owns all of its request tasks + /// and opaque conversation state. + let ask: YouTubeAskViewModel + let video: YouTubeVideo /// Invalidates stale in-flight loads when a newer one starts /// (SwiftUI restarts .task during launch/layout churn; latest wins). private var loadGeneration = 0 + private var lastAskAccountScope: YouTubeAskAccountScopeObservation? let client: any YouTubeClientProtocol private let logger = DiagnosticsLogger.api @@ -22,6 +57,7 @@ final class YouTubeWatchViewModel { init(video: YouTubeVideo, client: any YouTubeClientProtocol) { self.video = video self.client = client + self.ask = YouTubeAskViewModel(videoID: video.videoId, client: client) } // MARK: - Action State (optimistic) @@ -42,6 +78,7 @@ final class YouTubeWatchViewModel { /// Token for the next comments page. private var commentsContinuation: String? + private var commentsGeneration = 0 /// Params for posting a comment (nil = signed out / disabled). private(set) var createCommentParams: String? @@ -68,20 +105,36 @@ final class YouTubeWatchViewModel { /// Parent comments whose replies are currently loading. private(set) var loadingReplies: Set = [] - func load() async { + func load(accountScope: YouTubeAskAccountScopeObservation? = nil) async { + let accountScopeChanged: Bool + if let accountScope { + accountScopeChanged = self.lastAskAccountScope.map { $0 != accountScope } ?? false + self.lastAskAccountScope = accountScope + } else { + accountScopeChanged = false + } + + if accountScopeChanged { + self.resetAccountScopedWatchState() + } + + guard self.loadingState != .loaded else { return } self.loadGeneration += 1 let generation = self.loadGeneration + self.ask.cancelAndDiscard() self.loadingState = .loading do { - let data = try await self.client.getWatchNext(videoId: self.video.videoId) + let page = try await self.client.getWatchPage(videoId: self.video.videoId) guard generation == self.loadGeneration else { return } - self.data = data - self.isSubscribed = data.isSubscribed ?? false - self.commentsContinuation = data.commentsContinuation + self.data = page.data + self.isSubscribed = page.data.isSubscribed ?? false + self.commentsContinuation = page.data.commentsContinuation + self.ask.seed(page.askBootstrap) self.loadingState = .loaded await self.loadMoreComments() } catch { guard generation == self.loadGeneration else { return } + self.ask.cancelAndDiscard() // A cancelled load (view went away mid-flight) is not an // error; reset so the next task run reloads. if error is CancellationError { @@ -93,19 +146,54 @@ final class YouTubeWatchViewModel { } } + /// Invalidates the current route load and discards all Ask state. + func cancel() { + self.loadGeneration += 1 + self.commentsGeneration += 1 + self.isLoadingComments = false + self.isPostingComment = false + self.commentsContinuation = nil + self.loadingReplies = [] + self.ask.cancelAndDiscard() + self.loadingState = .idle + } + + private func resetAccountScopedWatchState() { + self.loadGeneration += 1 + self.commentsGeneration += 1 + self.data = .empty + self.ask.cancelAndDiscard() + self.isSubscribed = false + self.comments = [] + self.isLoadingComments = false + self.commentsContinuation = nil + self.createCommentParams = nil + self.isPostingComment = false + self.likedComments = [] + self.dislikedComments = [] + self.repliesByComment = [:] + self.loadingReplies = [] + self.loadingState = .idle + } + // MARK: - Comments /// Loads the next page of comments. func loadMoreComments() async { guard !self.isLoadingComments, let continuation = self.commentsContinuation else { return } + let generation = self.commentsGeneration self.isLoadingComments = true defer { - self.isLoadingComments = false + if generation == self.commentsGeneration { + self.isLoadingComments = false + } } do { let page = try await self.client.getComments(continuation: continuation) - guard self.commentsContinuation == continuation else { return } + guard generation == self.commentsGeneration, + self.commentsContinuation == continuation + else { return } let existing = Set(self.comments.map(\.id)) self.comments.append(contentsOf: page.comments.filter { !existing.contains($0.id) }) self.commentsContinuation = page.continuation @@ -116,6 +204,7 @@ final class YouTubeWatchViewModel { if error is CancellationError { return } + guard generation == self.commentsGeneration else { return } self.logger.error("Failed to load comments: \(error.localizedDescription)") self.commentsContinuation = nil } @@ -127,8 +216,10 @@ final class YouTubeWatchViewModel { guard let action = isLiked ? comment.unlikeAction : comment.likeAction else { return } + let generation = self.commentsGeneration do { try await self.client.performCommentAction(action) + guard generation == self.commentsGeneration else { return } if isLiked { self.likedComments.remove(comment.id) } else { @@ -137,6 +228,7 @@ final class YouTubeWatchViewModel { } HapticService.toggle() } catch { + guard generation == self.commentsGeneration else { return } self.logger.error("Failed to toggle comment like: \(error.localizedDescription)") } } @@ -147,8 +239,10 @@ final class YouTubeWatchViewModel { guard let action = isDisliked ? comment.undislikeAction : comment.dislikeAction else { return } + let generation = self.commentsGeneration do { try await self.client.performCommentAction(action) + guard generation == self.commentsGeneration else { return } if isDisliked { self.dislikedComments.remove(comment.id) } else { @@ -157,6 +251,7 @@ final class YouTubeWatchViewModel { } HapticService.toggle() } catch { + guard generation == self.commentsGeneration else { return } self.logger.error("Failed to toggle comment dislike: \(error.localizedDescription)") } } @@ -170,18 +265,23 @@ final class YouTubeWatchViewModel { return } + let generation = self.commentsGeneration self.loadingReplies.insert(comment.id) defer { - self.loadingReplies.remove(comment.id) + if generation == self.commentsGeneration { + self.loadingReplies.remove(comment.id) + } } do { let page = try await self.client.getComments(continuation: continuation) + guard generation == self.commentsGeneration else { return } // Reply pages can echo the parent; drop it. self.repliesByComment[comment.id] = page.comments.filter { $0.id != comment.id } } catch { if error is CancellationError { return } + guard generation == self.commentsGeneration else { return } self.logger.error("Failed to load replies: \(error.localizedDescription)") } } @@ -193,15 +293,20 @@ final class YouTubeWatchViewModel { return false } + let generation = self.commentsGeneration self.isPostingComment = true defer { - self.isPostingComment = false + if generation == self.commentsGeneration { + self.isPostingComment = false + } } do { try await self.client.postComment(text: trimmed, createCommentParams: params) + guard generation == self.commentsGeneration else { return false } HapticService.success() return true } catch { + guard generation == self.commentsGeneration else { return false } self.logger.error("Failed to post comment: \(error.localizedDescription)") HapticService.error() return false @@ -213,12 +318,15 @@ final class YouTubeWatchViewModel { /// Subscribes/unsubscribes the channel (optimistic with rollback). func toggleSubscribed() async { guard let channel = self.data.channel else { return } + let generation = self.loadGeneration let wasSubscribed = self.isSubscribed self.isSubscribed = !wasSubscribed do { try await self.client.setSubscribed(self.isSubscribed, channelId: channel.channelId) + guard generation == self.loadGeneration else { return } HapticService.toggle() } catch { + guard generation == self.loadGeneration else { return } self.logger.error("Failed to change subscription: \(error.localizedDescription)") self.isSubscribed = wasSubscribed } diff --git a/Sources/Kaset/Views/YouTube/YouTubeAskPanelView.swift b/Sources/Kaset/Views/YouTube/YouTubeAskPanelView.swift new file mode 100644 index 000000000..e3c33831f --- /dev/null +++ b/Sources/Kaset/Views/YouTube/YouTubeAskPanelView.swift @@ -0,0 +1,264 @@ +import AppKit +import SwiftUI + +// MARK: - YouTubeAskPanelView + +/// Collapsible, watch-scoped Ask Gemini panel. It only presents server-issued +/// suggestions; free-form input is intentionally not part of this surface. +struct YouTubeAskPanelView: View { + let viewModel: YouTubeAskViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + self.header + + Text( + "Responses are generated by YouTube and may be inaccurate.", + comment: "Disclosure shown in the YouTube Ask Gemini panel" + ) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + if self.viewModel.isExpanded { + Divider() + .opacity(0.4) + + self.expandedContent + } + } + .padding(14) + .compatGlass(in: .rect(cornerRadius: 14)) + .accessibilityElement(children: .contain) + .accessibilityIdentifier(AccessibilityID.YouTubeContent.askPanel) + .onChange(of: self.viewModel.accessibilityAnnouncementSequence) { _, _ in + guard let announcement = self.viewModel.accessibilityAnnouncement else { return } + self.postLiveRegionAnnouncement(self.accessibilityText(for: announcement)) + } + .onChange(of: self.viewModel.presentationError) { _, error in + guard let error else { return } + self.postLiveRegionAnnouncement(self.errorText(for: error)) + } + } + + private var header: some View { + Button { + self.viewModel.toggleExpanded() + } label: { + HStack(spacing: 9) { + Image(systemName: "sparkles") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.tint) + + Text("Ask Gemini", comment: "YouTube watch-page Ask Gemini panel title") + .font(.headline) + .foregroundStyle(.primary) + + Spacer(minLength: 8) + + Image(systemName: self.viewModel.isExpanded ? "chevron.up" : "chevron.down") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel( + self.viewModel.isExpanded + ? String(localized: "Collapse Ask Gemini") + : String(localized: "Expand Ask Gemini") + ) + .accessibilityIdentifier(AccessibilityID.YouTubeContent.askToggle) + } + + private var expandedContent: some View { + VStack(alignment: .leading, spacing: 12) { + if !self.viewModel.messages.isEmpty { + self.transcript + } + + if let error = self.viewModel.presentationError { + self.errorStatus(error) + } else if self.viewModel.activity != .idle { + self.progressStatus + } + + if !self.viewModel.suggestions.isEmpty, !self.viewModel.requiresNewChat { + self.suggestions + } + + if self.viewModel.canStartNewChat { + Button { + self.viewModel.startNewChat() + } label: { + Label(String(localized: "New Chat"), systemImage: "arrow.clockwise") + .font(.system(size: 12, weight: .semibold)) + .frame(maxWidth: .infinity) + .padding(.vertical, 7) + } + .buttonStyle(.bordered) + .disabled(self.viewModel.isBusy) + .accessibilityIdentifier(AccessibilityID.YouTubeContent.askNewChat) + } + } + } + + private var transcript: some View { + ScrollViewReader { proxy in + ScrollView(.vertical) { + LazyVStack(alignment: .leading, spacing: 10) { + ForEach(self.viewModel.messages) { message in + self.messageView(message) + .id(message.id) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.trailing, 4) + } + .frame(maxHeight: 240) + .accessibilityIdentifier(AccessibilityID.YouTubeContent.askTranscript) + .onChange(of: self.viewModel.messages.map(\.id)) { _, messageIDs in + guard let lastID = messageIDs.last else { return } + proxy.scrollTo(lastID, anchor: .bottom) + } + } + } + + @ViewBuilder + private func messageView(_ message: YouTubeAskMessage) -> some View { + switch message.role { + case .user: + Text(verbatim: message.text) + .font(.callout.weight(.medium)) + .foregroundStyle(.primary) + .multilineTextAlignment(.leading) + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background(Color.accentColor.opacity(0.12), in: .rect(cornerRadius: 10)) + .frame(maxWidth: .infinity, alignment: .trailing) + .accessibilityLabel( + String( + localized: "You asked: \(message.text)", + comment: "VoiceOver label for a user turn in the YouTube Ask Gemini transcript" + ) + ) + case .assistant: + Text(verbatim: message.text) + .font(.callout) + .foregroundStyle(.primary) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityLabel( + String( + localized: "YouTube response: \(message.text)", + comment: "VoiceOver label for an assistant turn in the YouTube Ask Gemini transcript" + ) + ) + } + } + + private var suggestions: some View { + VStack(alignment: .leading, spacing: 8) { + ForEach(Array(self.viewModel.suggestions.enumerated()), id: \.element.id) { index, suggestion in + Button { + self.viewModel.selectSuggestion(id: suggestion.id) + } label: { + Text(verbatim: suggestion.text) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.primary) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 11) + .padding(.vertical, 9) + .background(.quaternary.opacity(0.55), in: .rect(cornerRadius: 10)) + .contentShape(.rect(cornerRadius: 10)) + } + .buttonStyle(.plain) + .disabled(self.viewModel.isBusy) + .accessibilityIdentifier(AccessibilityID.YouTubeContent.askSuggestion(index: index)) + } + } + } + + private var progressStatus: some View { + HStack(spacing: 8) { + ProgressView() + .controlSize(.small) + Text(self.activityText) + .font(.caption) + .foregroundStyle(.secondary) + } + .accessibilityElement(children: .combine) + .accessibilityIdentifier(AccessibilityID.YouTubeContent.askStatus) + } + + private func errorStatus(_ error: YouTubeAskPresentationError) -> some View { + Label(self.errorText(for: error), systemImage: "exclamationmark.triangle") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .accessibilityIdentifier(AccessibilityID.YouTubeContent.askStatus) + } + + private var activityText: String { + switch self.viewModel.activity { + case .idle: + "" + case .preparing: + String(localized: "Preparing Ask Gemini…") + case .sending: + String(localized: "Sending…") + } + } + + private func errorText(for error: YouTubeAskPresentationError) -> String { + switch error { + case .authentication: + String(localized: "Sign in again to use Ask Gemini.") + case .rateLimited: + String(localized: "Ask Gemini is temporarily rate limited. Try again later.") + case .preparation: + String(localized: "Ask Gemini couldn’t prepare this chat.") + case .restartRequired: + String(localized: "Start a new chat to continue.") + } + } + + private func accessibilityText(for announcement: YouTubeAskViewModel.AccessibilityAnnouncement) -> String { + switch announcement { + case .responseReady: + String(localized: "Ask Gemini response ready") + case .newChatReady: + String(localized: "New Ask Gemini chat ready") + } + } + + private func postLiveRegionAnnouncement(_ message: String) { + NSAccessibility.post( + element: NSApplication.shared, + notification: .announcementRequested, + userInfo: [ + .announcement: message, + .priority: NSAccessibilityPriorityLevel.medium.rawValue, + ] + ) + } +} + +// MARK: - AccessibilityID.YouTubeContent + +extension AccessibilityID.YouTubeContent { + static let askPanel = "youtubeContent.askPanel" + static let askToggle = "youtubeContent.askToggle" + static let askTranscript = "youtubeContent.askTranscript" + static let askNewChat = "youtubeContent.askNewChat" + static let askStatus = "youtubeContent.askStatus" + + static func askSuggestion(index: Int) -> String { + "youtubeContent.askSuggestion.\(index)" + } +} diff --git a/Sources/Kaset/Views/YouTube/YouTubeWatchView.swift b/Sources/Kaset/Views/YouTube/YouTubeWatchView.swift index 8e4791d21..8878634c2 100644 --- a/Sources/Kaset/Views/YouTube/YouTubeWatchView.swift +++ b/Sources/Kaset/Views/YouTube/YouTubeWatchView.swift @@ -14,6 +14,7 @@ struct YouTubeWatchView: View { let video: YouTubeVideo @Environment(AuthService.self) private var authService + @Environment(AccountService.self) private var accountService @Environment(YouTubePlayerService.self) private var youtubePlayer @State private var viewModel: YouTubeWatchViewModel @@ -53,6 +54,16 @@ struct YouTubeWatchView: View { return self.youtubePlayer.storyboardSpec } + private var askAccountScope: YouTubeAskAccountScopeObservation { + YouTubeAskAccountScopeObservation( + authenticationGeneration: self.authService.accountIdentityGeneration, + hasPersonalAccount: self.authService.hasPersonalAccount, + accountScopeID: self.accountService.currentAccountScopeID, + isPrimaryAccount: self.accountService.currentAccount?.isPrimary, + verifiedIdentitySequence: self.accountService.verifiedIdentitySequence + ) + } + var body: some View { ScrollView { VStack(alignment: .leading, spacing: 16) { @@ -76,7 +87,7 @@ struct YouTubeWatchView: View { } .frame(maxWidth: .infinity, alignment: .leading) - self.relatedColumn + self.rightColumn .frame(width: 360) } } @@ -102,9 +113,10 @@ struct YouTubeWatchView: View { self.ambientStylePicker } #endif - .task { + .task(id: self.askAccountScope) { + let accountScope = self.askAccountScope self.startOrAdoptPlayback() - await self.viewModel.load() + await self.viewModel.load(accountScope: accountScope) // Feed the related list to the player so the bar's next/previous // buttons can skip between videos. if self.youtubePlayer.currentVideo?.videoId == self.video.videoId { @@ -113,6 +125,7 @@ struct YouTubeWatchView: View { } } .onDisappear { + self.viewModel.cancel() self.youtubePlayer.inlineSurfaceWillDisappear(videoId: self.video.videoId) } } @@ -400,6 +413,18 @@ struct YouTubeWatchView: View { return currentTime < self.viewModel.data.chapters[nextIndex].startTime } + // MARK: - Right Column + + private var rightColumn: some View { + VStack(alignment: .leading, spacing: 16) { + if self.viewModel.ask.isAvailable { + YouTubeAskPanelView(viewModel: self.viewModel.ask) + } + + self.relatedColumn + } + } + // MARK: - Related Column private var relatedColumn: some View { diff --git a/Tests/KasetTests/AppLocalizationTests.swift b/Tests/KasetTests/AppLocalizationTests.swift index 2465c5de6..8c413e106 100644 --- a/Tests/KasetTests/AppLocalizationTests.swift +++ b/Tests/KasetTests/AppLocalizationTests.swift @@ -378,6 +378,32 @@ struct AppLocalizationTests { #expect(title.contains("34.6M")) } + @Test("Ask Gemini UI strings resolve from representative runtime bundles") + func askGeminiRuntimeStringsResolve() { + let expectedValues = [ + ("ar", "New Chat", "محادثة جديدة"), + ("de", "Ask Gemini", "Gemini fragen"), + ("ko", "YouTube response: %@", "YouTube 응답: %@"), + ("tr", "Sending…", "Gönderiliyor…"), + ] + + for (locale, key, expectedValue) in expectedValues { + #expect(self.localizedValue(key: key, localeIdentifier: locale) == expectedValue) + } + + let arabicTemplate = self.localizedValue( + key: "You asked: %@", + localeIdentifier: "ar" + ) + let arabicLabel = String( + format: arabicTemplate, + locale: Locale(identifier: "ar"), + "محتوى تجريبي" + ) + #expect(arabicLabel.hasPrefix("لقد سألت:")) + #expect(arabicLabel.contains("محتوى تجريبي")) + } + @Test("Override bundle lookup is scoped to Kaset-owned bundles") func overrideBundleLookupIsScopedToKasetBundles() throws { AppLocalization.setLanguage("ar") diff --git a/Tests/KasetTests/YouTubeAskClientTests.swift b/Tests/KasetTests/YouTubeAskClientTests.swift index 563c63d0b..55e61090c 100644 --- a/Tests/KasetTests/YouTubeAskClientTests.swift +++ b/Tests/KasetTests/YouTubeAskClientTests.swift @@ -501,9 +501,9 @@ struct YouTubeAskClientTests { #expect(requestCount.count == 1) } - @Test("Production remains disabled when no parity profile has passed") + @Test("Client default remains disabled without explicit opt-in") @MainActor - func productionDefaultRemainsDisabled() async throws { + func clientDefaultRemainsDisabled() async throws { let session = MockURLProtocol.makeMockSession { request in Self.response(for: request, data: Self.eligibleNextData) } @@ -532,6 +532,17 @@ struct YouTubeAskClientTests { #expect(page.askBootstrap == nil) } + @Test("Production app explicitly enables Ask Gemini") + func productionAppExplicitlyEnablesAsk() throws { + let sourcePath = #filePath.replacingOccurrences( + of: "Tests/KasetTests/YouTubeAskClientTests.swift", + with: "Sources/Kaset/KasetApp.swift" + ) + let source = try String(contentsOfFile: sourcePath, encoding: .utf8) + + #expect(source.contains("askFeatureEnabled: true")) + } + @Test("Unresolved and unsupported account states omit Ask") @MainActor func unresolvedAccountOmitsAsk() async throws { diff --git a/Tests/KasetTests/YouTubeAskViewModelTests.swift b/Tests/KasetTests/YouTubeAskViewModelTests.swift new file mode 100644 index 000000000..0b41268a0 --- /dev/null +++ b/Tests/KasetTests/YouTubeAskViewModelTests.swift @@ -0,0 +1,404 @@ +import Foundation +import Testing +@testable import Kaset + +@Suite("YouTube Ask view models", .serialized, .tags(.viewModel), .timeLimit(.minutes(1))) +@MainActor +struct YouTubeAskViewModelTests { + @Test("Ask remains collapsed and does not prepare until expanded") + func collapsedDefaultAndLazyPreparation() async { + let client = MockYouTubeClient() + let bootstrap = YouTubeAskBootstrap.testing(suggestions: ["Explain the main idea"]) + let sut = YouTubeAskViewModel(videoID: "fixture-video", client: client) + + sut.seed(bootstrap) + + #expect(sut.isAvailable) + #expect(!sut.isExpanded) + #expect(sut.activity == .idle) + #expect(client.loadAskConversationCallCount == 0) + + sut.setExpanded(true) + await self.waitUntil(client.loadAskConversationCallCount == 1 && sut.activity == .idle) + + #expect(sut.isExpanded) + #expect(sut.messages.isEmpty) + #expect(sut.suggestions.map(\.text) == ["Explain the main idea"]) + #expect(client.continueAskConversationCallCount == 0) + } + + @Test("Suggestion selection publishes the user turn, stays single-flight, and preserves server order") + func selectionIsSingleFlightAndOrdered() async throws { + let client = MockYouTubeClient() + let bootstrap = YouTubeAskBootstrap.testing(suggestions: ["Explain this"]) + let sut = YouTubeAskViewModel(videoID: "fixture-video", client: client) + sut.seed(bootstrap) + sut.setExpanded(true) + await self.waitUntil(sut.activity == .idle && !sut.suggestions.isEmpty) + + client.continuedAskConversation = YouTubeAskConversation.testing( + messages: [ + YouTubeAskMessage(role: .user, text: "Explain this"), + YouTubeAskMessage(role: .assistant, text: "First assistant message"), + YouTubeAskMessage(role: .assistant, text: "Second assistant message"), + ], + suggestions: ["First follow-up", "Second follow-up"] + ) + let gate = AsyncGate() + client.beforeAskContinuationReturn = { + await gate.wait() + } + + let suggestion = try #require(sut.suggestions.first) + sut.selectSuggestion(id: suggestion.id) + await self.waitUntil(client.continueAskConversationCallCount == 1) + + #expect(sut.activity == .sending) + #expect(sut.messages.map(\.text) == ["Explain this"]) + if let firstMessage = sut.messages.first { + if case .user = firstMessage.role { + // Expected visible optimistic user turn. + } else { + Issue.record("The optimistic turn must be a user message") + } + } + + sut.selectSuggestion(id: suggestion.id) + await Task.yield() + #expect(client.continueAskConversationCallCount == 1) + + await gate.open() + await self.waitUntil(sut.activity == .idle && sut.messages.count == 3) + + #expect(sut.messages.map(\.text) == [ + "Explain this", + "First assistant message", + "Second assistant message", + ]) + #expect(sut.suggestions.map(\.text) == ["First follow-up", "Second follow-up"]) + #expect(sut.accessibilityAnnouncement == .responseReady) + } + + @Test("Submission failure consumes the session and requires New Chat") + func submissionFailureRequiresNewChat() async throws { + let client = MockYouTubeClient() + let sut = YouTubeAskViewModel(videoID: "fixture-video", client: client) + sut.seed(YouTubeAskBootstrap.testing(suggestions: ["Summarize this"])) + sut.setExpanded(true) + await self.waitUntil(sut.activity == .idle && !sut.suggestions.isEmpty) + + client.askError = YouTubeAskClientError.rateLimited + let suggestion = try #require(sut.suggestions.first) + sut.selectSuggestion(id: suggestion.id) + await self.waitUntil(sut.activity == .idle && sut.requiresNewChat) + + #expect(sut.messages.map(\.text) == ["Summarize this"]) + #expect(sut.suggestions.isEmpty) + #expect(sut.presentationError == .rateLimited) + #expect(sut.canStartNewChat) + + sut.selectSuggestion(id: suggestion.id) + await Task.yield() + #expect(client.continueAskConversationCallCount == 1) + } + + @Test("New Chat keeps the old conversation on failure and swaps only after preparation succeeds") + func newChatIsTransactional() async throws { + let client = MockYouTubeClient() + let sut = YouTubeAskViewModel(videoID: "fixture-video", client: client) + sut.seed(YouTubeAskBootstrap.testing(suggestions: ["Initial question"])) + sut.setExpanded(true) + await self.waitUntil(sut.activity == .idle && !sut.suggestions.isEmpty) + + client.continuedAskConversation = YouTubeAskConversation.testing( + messages: [ + YouTubeAskMessage(role: .user, text: "Initial question"), + YouTubeAskMessage(role: .assistant, text: "Initial answer"), + ], + suggestions: ["Continue old chat"] + ) + let initialSuggestion = try #require(sut.suggestions.first) + sut.selectSuggestion(id: initialSuggestion.id) + await self.waitUntil(sut.activity == .idle && sut.hasStarted) + + let oldMessages = sut.messages.map(\.text) + let oldSuggestions = sut.suggestions.map(\.text) + let freshBootstrap = YouTubeAskBootstrap.testing(suggestions: ["Fresh question"]) + client.watchPages = [YouTubeWatchPage(data: .empty, askBootstrap: freshBootstrap)] + client.askError = YouTubeAskClientError.unavailable + + sut.startNewChat() + await self.waitUntil(sut.activity == .idle && client.getWatchPageCallCount == 1) + + #expect(sut.messages.map(\.text) == oldMessages) + #expect(sut.suggestions.map(\.text) == oldSuggestions) + #expect(sut.presentationError == .preparation) + #expect(!sut.requiresNewChat) + + client.askError = nil + client.askConversation = YouTubeAskConversation.testing(suggestions: ["Fresh question"]) + client.watchPages = [YouTubeWatchPage(data: .empty, askBootstrap: freshBootstrap)] + + sut.startNewChat() + await self.waitUntil( + sut.activity == .idle + && client.getWatchPageCallCount == 2 + && sut.suggestions.map(\.text) == ["Fresh question"] + ) + + #expect(sut.messages.isEmpty) + #expect(!sut.requiresNewChat) + #expect(sut.presentationError == nil) + #expect(sut.accessibilityAnnouncement == .newChatReady) + } + + @Test("A repeated watch task preserves the active conversation") + func repeatedWatchTaskPreservesConversation() async throws { + let client = MockYouTubeClient() + client.askBootstrap = YouTubeAskBootstrap.testing(suggestions: ["Explain this video"]) + let video = MockYouTubeClient.makeVideo(videoId: "fixture-video") + let sut = YouTubeWatchViewModel(video: video, client: client) + + await sut.load() + sut.ask.setExpanded(true) + await self.waitUntil(sut.ask.activity == .idle && !sut.ask.suggestions.isEmpty) + + client.continuedAskConversation = YouTubeAskConversation.testing( + messages: [ + YouTubeAskMessage(role: .user, text: "Explain this video"), + YouTubeAskMessage(role: .assistant, text: "Fixture answer"), + ], + suggestions: ["Continue"] + ) + let suggestion = try #require(sut.ask.suggestions.first) + sut.ask.selectSuggestion(id: suggestion.id) + await self.waitUntil(sut.ask.activity == .idle && sut.ask.hasStarted) + + await sut.load() + + #expect(client.getWatchPageCallCount == 1) + #expect(sut.ask.messages.map(\.text) == ["Explain this video", "Fixture answer"]) + #expect(sut.ask.suggestions.map(\.text) == ["Continue"]) + } + + @Test("Returning to the same watch route reloads discarded Ask state") + func cancelThenReloadRestoresAskAvailability() async { + let client = MockYouTubeClient() + client.watchPages = [ + YouTubeWatchPage( + data: .empty, + askBootstrap: YouTubeAskBootstrap.testing(suggestions: ["Initial question"]) + ), + YouTubeWatchPage( + data: .empty, + askBootstrap: YouTubeAskBootstrap.testing(suggestions: ["Fresh question"]) + ), + ] + let video = MockYouTubeClient.makeVideo(videoId: "fixture-video") + let sut = YouTubeWatchViewModel(video: video, client: client) + let accountScope = self.accountScope(sequence: 1) + + await sut.load(accountScope: accountScope) + #expect(sut.ask.isAvailable) + + sut.cancel() + #expect(!sut.ask.isAvailable) + #expect(sut.loadingState == .idle) + + await sut.load(accountScope: accountScope) + + #expect(client.getWatchPageCallCount == 2) + #expect(sut.ask.isAvailable) + #expect(!sut.ask.isExpanded) + + sut.ask.setExpanded(true) + await self.waitUntil(sut.ask.activity == .idle && !sut.ask.suggestions.isEmpty) + #expect(sut.ask.suggestions.map(\.text) == ["Fresh question"]) + } + + @Test("A verified primary account refreshes Ask without reusing unresolved state") + func verifiedAccountRefreshesAskAvailability() async { + let client = MockYouTubeClient() + client.commentsPage = YouTubeCommentsPage( + comments: [ + YouTubeComment( + id: "fixture-old-comment", + author: "Fixture author", + authorAvatarURL: nil, + text: "Old account comment", + publishedText: nil, + likeCountText: nil + ), + ], + continuation: nil, + createCommentParams: "fixture-old-comment-params" + ) + client.watchPages = [ + YouTubeWatchPage( + data: WatchNextData( + videoTitle: "Old account title", + viewCountText: nil, + publishedText: nil, + channel: nil, + related: [], + isSubscribed: true, + commentsContinuation: "fixture-old-comments" + ), + askBootstrap: nil + ), + YouTubeWatchPage( + data: WatchNextData( + videoTitle: "Verified account title", + viewCountText: nil, + publishedText: nil, + channel: nil, + related: [], + isSubscribed: false, + commentsContinuation: nil + ), + askBootstrap: YouTubeAskBootstrap.testing(suggestions: ["Verified question"]) + ), + ] + let video = MockYouTubeClient.makeVideo(videoId: "fixture-video") + let sut = YouTubeWatchViewModel(video: video, client: client) + + await sut.load(accountScope: self.accountScope( + sequence: 0, + hasPersonalAccount: false, + accountScopeID: nil, + isPrimaryAccount: nil + )) + #expect(!sut.ask.isAvailable) + #expect(sut.isSubscribed) + #expect(sut.comments.map(\.id) == ["fixture-old-comment"]) + #expect(sut.canComment) + + client.commentsPage = .empty + await sut.load(accountScope: self.accountScope(sequence: 1)) + + #expect(client.getWatchPageCallCount == 2) + #expect(sut.data.videoTitle == "Verified account title") + #expect(!sut.isSubscribed) + #expect(sut.comments.isEmpty) + #expect(!sut.canComment) + #expect(sut.ask.isAvailable) + #expect(!sut.ask.isExpanded) + } + + @Test("A stale account refresh cannot replace the latest Ask bootstrap") + func staleAccountRefreshIsDiscarded() async { + let client = MockYouTubeClient() + client.watchPages = [YouTubeWatchPage(data: .empty, askBootstrap: nil)] + let video = MockYouTubeClient.makeVideo(videoId: "fixture-video") + let sut = YouTubeWatchViewModel(video: video, client: client) + + await sut.load(accountScope: self.accountScope(sequence: 0)) + + let gate = AsyncGate() + client.beforeWatchPageReturnByCallCount = { callCount in + if callCount == 2 { + await gate.wait() + } + } + client.askBootstrap = YouTubeAskBootstrap.testing(suggestions: ["Stale question"]) + + let staleRefresh = Task { + await sut.load(accountScope: self.accountScope(sequence: 1)) + } + await self.waitUntil(client.getWatchPageCallCount == 2) + + client.watchPages = [YouTubeWatchPage( + data: .empty, + askBootstrap: YouTubeAskBootstrap.testing(suggestions: ["Latest question"]) + )] + await sut.load(accountScope: self.accountScope(sequence: 2)) + + await gate.open() + await staleRefresh.value + + #expect(client.getWatchPageCallCount == 3) + #expect(sut.ask.isAvailable) + sut.ask.setExpanded(true) + await self.waitUntil(sut.ask.activity == .idle && !sut.ask.suggestions.isEmpty) + #expect(sut.ask.suggestions.map(\.text) == ["Latest question"]) + } + + @Test("Lifecycle cancellation discards state and rejects late preparation") + func cancellationDiscardsState() async { + let client = MockYouTubeClient() + let gate = AsyncGate() + client.beforeAskPreparationReturn = { + await gate.wait() + } + let sut = YouTubeAskViewModel(videoID: "fixture-video", client: client) + sut.seed(YouTubeAskBootstrap.testing(suggestions: ["Explain this"])) + sut.setExpanded(true) + await self.waitUntil(client.loadAskConversationCallCount == 1) + + sut.cancelAndDiscard() + await gate.open() + await Task.yield() + await Task.yield() + + #expect(!sut.isAvailable) + #expect(!sut.isExpanded) + #expect(sut.activity == .idle) + #expect(sut.conversation == nil) + #expect(sut.messages.isEmpty) + #expect(sut.suggestions.isEmpty) + } + + @Test("Watch load uses one watch-page response and seeds a collapsed child") + func watchViewModelSeedsAskFromWatchPage() async { + let client = MockYouTubeClient() + client.watchNextData = WatchNextData( + videoTitle: "Fixture title", + viewCountText: nil, + publishedText: nil, + channel: nil, + related: [] + ) + client.askBootstrap = YouTubeAskBootstrap.testing(suggestions: ["Explain this video"]) + let video = MockYouTubeClient.makeVideo(videoId: "fixture-video") + let sut = YouTubeWatchViewModel(video: video, client: client) + + await sut.load() + + #expect(client.getWatchPageCallCount == 1) + #expect(client.getWatchNextCallCount == 0) + #expect(sut.data.videoTitle == "Fixture title") + #expect(sut.ask.isAvailable) + #expect(!sut.ask.isExpanded) + #expect(client.loadAskConversationCallCount == 0) + + sut.cancel() + #expect(!sut.ask.isAvailable) + } + + private func waitUntil( + _ condition: @autoclosure () -> Bool, + timeout: Duration = .seconds(2) + ) async { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while !condition(), clock.now < deadline { + await Task.yield() + } + #expect(condition()) + } + + private func accountScope( + sequence: Int, + hasPersonalAccount: Bool = true, + accountScopeID: String? = "fixture-primary-scope", + isPrimaryAccount: Bool? = true + ) -> YouTubeAskAccountScopeObservation { + YouTubeAskAccountScopeObservation( + authenticationGeneration: UInt64(sequence), + hasPersonalAccount: hasPersonalAccount, + accountScopeID: accountScopeID, + isPrimaryAccount: isPrimaryAccount, + verifiedIdentitySequence: sequence + ) + } +} diff --git a/Tests/KasetTests/YouTubeLibraryViewModelTests.swift b/Tests/KasetTests/YouTubeLibraryViewModelTests.swift index 9c029a574..921bbf121 100644 --- a/Tests/KasetTests/YouTubeLibraryViewModelTests.swift +++ b/Tests/KasetTests/YouTubeLibraryViewModelTests.swift @@ -230,6 +230,73 @@ struct YouTubeWatchViewModelActionTests { #expect(client.postedComments.first?.params == "create-params") } + @Test("Route cancellation clears comment posting and reply busy states") + func cancellationClearsCommentBusyStates() async { + let client = MockYouTubeClient() + client.watchNextData = WatchNextData( + videoTitle: "Title", + viewCountText: nil, + publishedText: nil, + channel: nil, + related: [], + commentsContinuation: "comments-token" + ) + client.commentsPage = YouTubeCommentsPage( + comments: [], + continuation: nil, + createCommentParams: "create-params" + ) + let sut = YouTubeWatchViewModel( + video: MockYouTubeClient.makeVideo(videoId: "abc"), + client: client + ) + await sut.load() + + let postGate = AsyncGate() + client.beforePostCommentReturn = { + await postGate.wait() + } + let postTask = Task { + await sut.postComment(text: "Hello") + } + await self.waitUntil(sut.isPostingComment) + + sut.cancel() + #expect(!sut.isPostingComment) + await postGate.open() + #expect(await postTask.value == false) + + client.beforePostCommentReturn = nil + #expect(await sut.postComment(text: "Hello again")) + + let replyGate = AsyncGate() + client.beforeCommentsReturn = { _ in + await replyGate.wait() + } + let comment = YouTubeComment( + id: "reply-parent", + author: "@a", + authorAvatarURL: nil, + text: "Parent", + publishedText: nil, + likeCountText: nil, + repliesContinuation: "replies-token" + ) + let replyTask = Task { + await sut.loadReplies(for: comment) + } + await self.waitUntil(sut.loadingReplies.contains(comment.id)) + + sut.cancel() + #expect(sut.loadingReplies.isEmpty) + await replyGate.open() + await replyTask.value + + client.beforeCommentsReturn = nil + await sut.loadReplies(for: comment) + #expect(sut.loadingReplies.isEmpty) + } + @Test("Posting without create params is rejected") func postWithoutParamsRejected() async { let client = MockYouTubeClient() @@ -294,6 +361,18 @@ struct YouTubeWatchViewModelActionTests { #expect(client.subscriptionChanges.first?.channelId == "UCxyz") #expect(client.subscriptionChanges.first?.subscribed == true) } + + private func waitUntil( + _ condition: @autoclosure () -> Bool, + timeout: Duration = .seconds(2) + ) async { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while !condition(), clock.now < deadline { + await Task.yield() + } + #expect(condition()) + } } // MARK: - GuideParserTests diff --git a/docs/adr/0032-youtube-ask-gemini.md b/docs/adr/0032-youtube-ask-gemini.md index 838c2900d..e9846629a 100644 --- a/docs/adr/0032-youtube-ask-gemini.md +++ b/docs/adr/0032-youtube-ask-gemini.md @@ -2,7 +2,7 @@ ## Status -Accepted; production activation is disabled pending request-profile validation. +Accepted; production activation uses the explicitly selected fixed WEB profile. ## Context @@ -64,11 +64,12 @@ Wire-level observations and the API Explorer workflow remain documented in the disable the current session. There is no automatic retry; the UI may offer New Chat, which starts from a fresh watch bootstrap and replaces the old conversation only after preparation succeeds. -6. **Require a passing request profile before production activation.** No - profile is currently selected. The production feature remains disabled and - fail-closed until a redacted parity run establishes a passing signed-in - primary-account profile. Added compatibility configuration, if any, must - remain isolated to Ask requests. The +6. **Select the production request profile explicitly.** The production app + selects the fixed WEB profile. The July 28, 2026 parity run was inconclusive + because the exported session appeared signed out; activation was subsequently + enabled by explicit product direction on July 30, 2026. Compatibility + configuration remains isolated to Ask requests, and malformed, ineligible, or + identity-mismatched responses continue to fail closed. The [API discovery record](../api-discovery.md#youtube-ask-gemini--youchat-investigation-2026-07-27) is the sole wire-level source of truth. @@ -83,8 +84,7 @@ Wire-level observations and the API Explorer workflow remain documented in the boundary and at app termination. - Opaque command material is harder to inspect during debugging, but accidental disclosure and cross-account reuse are substantially less likely. -- A completed UI and domain implementation may remain invisible when no request - profile has passed. This is intentional: HTTP success alone is not evidence of - authenticated eligibility. -- Future enablement requires updating the redacted parity result and tests, not - guessing request fields or weakening parser rules. +- The panel remains invisible for signed-out, guest, brand-account, and + server-ineligible watch routes even though the production capability is enabled. +- Future request-profile changes require updating the redacted parity result and + tests, not guessing request fields or weakening parser rules. diff --git a/docs/adr/README.md b/docs/adr/README.md index 711208be0..4fc7ee573 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -66,4 +66,4 @@ What becomes easier or more difficult because of this change? | [0029](0029-now-playing-tracklist-provider.md) | Shared Now-Playing Mix Tracklist Provider | Accepted | | [0030](0030-account-scoped-favorites.md) | Account-Scoped Favorites Persistence | Accepted | | [0031](0031-saved-album-library-reconciliation.md) | Saved-Album Library Identity and Reconciliation | Accepted | -| [0032](0032-youtube-ask-gemini.md) | Watch-Scoped YouTube Ask Gemini | Accepted; production disabled pending profile validation | +| [0032](0032-youtube-ask-gemini.md) | Watch-Scoped YouTube Ask Gemini | Accepted; fixed WEB profile enabled in production | diff --git a/docs/api-discovery.md b/docs/api-discovery.md index 258fe4bce..d2ba175e4 100644 --- a/docs/api-discovery.md +++ b/docs/api-discovery.md @@ -1586,6 +1586,7 @@ The `--brand` flag sets `context.user.onBehalfOfUser` in the request body. See [ | Date | Changes | |------|---------| +| 2026-07-30 | Enabled the fixed WEB Ask request profile in the production app by explicit product direction; eligibility and all strict parser, identity, and transport gates remain enforced | | 2026-07-28 | Added redacted read-only `ask-video-parity` tooling backed by `YouTubeAskCore`; all three profiles returned HTTP 200 `next` responses but the exported session was treated as signed out, so no profile passed and production remains disabled | | 2026-07-27 | Live-validated YouTube Ask Gemini / YouChat summary, follow-up, and two fresh chats; added guarded `ask-video-live-test`, corrected direct chips to `get_panel`, retained read-only `ask-video-audit`, and documented redaction/auth constraints | | 2026-07-19 | Revalidated Music search: `itemSectionRenderer` mixed rows, watch-endpoint Top Results, audiobooks, videos/profiles/episodes filters, shelf and action-envelope continuations, and `/search` routing; added `search-audit` | diff --git a/docs/architecture.md b/docs/architecture.md index c2b50ccea..69754887d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -24,8 +24,9 @@ Sources/ ├── ViewModels/ → State management (music view models plus YouTube/ video-source view models) ├── Utilities/ → Helpers (DiagnosticsLogger, extensions) └── Views/ → SwiftUI views (MainWindow, Sidebar, PlayerBar, YouTube views, etc.) - └── APIExplorer/ → API explorer CLI tool -Tests/ → Unit tests (KasetTests/) + ├── APIExplorer/ → API explorer CLI tool + └── YouTubeAskCore/ → Foundation-only Ask wire decoding, strict parsing, sanitization, and request construction +Tests/ → Unit tests (`KasetTests/` and isolated `YouTubeAskCoreTests/`) docs/ → Documentation └── adr/ → Architecture Decision Records ``` @@ -57,7 +58,7 @@ final class HomeViewModel { } ``` -YouTube view models follow the same pattern with `YouTubeClientProtocol` and live under `Sources/Kaset/ViewModels/YouTube/`. `YouTubeViewModelStore` keeps the YouTube navigation stack and view-model caches warm while the user switches back to Music. +YouTube view models follow the same pattern with `YouTubeClientProtocol` and live under `Sources/Kaset/ViewModels/YouTube/`. `YouTubeViewModelStore` keeps the YouTube navigation stack and view-model caches warm while the user switches back to Music. Watch-scoped Ask state is deliberately excluded from that store: `YouTubeWatchViewModel` owns a child `YouTubeAskViewModel`, so opaque conversation state cannot survive route destruction or a source/account boundary. ## State Management @@ -69,6 +70,10 @@ YouTube view models follow the same pattern with `YouTubeClientProtocol` and liv `LibraryViewModel` owns observable Library UI state, while `LibraryContentReconciler` owns optimistic add/remove reconciliation for eventually-consistent YouTube Music Library responses. `LibraryMutationActions` owns mutation orchestration: calling YouTube Music, invalidating stale caches, applying optimistic state, and scheduling delayed reconciliation when backend snapshots lag. This keeps pending mutation stabilization rules behind small Library interfaces instead of spreading them across view models and action helpers. +### YouTube Ask State Boundary + +Ask Gemini uses a route-owned, memory-only state machine. Visible messages and local suggestion IDs are separated from opaque server commands. Hidden state is bound to the video ID, authentication generation, confirmed primary-account scope, local conversation ID, and revision. Navigation away, source/account/authentication changes, cancellation, or view-model destruction invalidates the operation and discards both visible and opaque conversation state. Nothing is written to `APICache`, UserDefaults, Keychain, navigation restoration, telemetry, or logs. See [ADR-0032](adr/0032-youtube-ask-gemini.md). + ## Key Services ### WebKitManager @@ -152,18 +157,21 @@ Makes authenticated requests to regular YouTube's internal InnerTube API. It del - Uses `https://www.youtube.com` for `SAPISIDHASH`, `Origin`, `Referer`, and `X-Origin` - Uses the `WEB` InnerTube client instead of YouTube Music's `WEB_REMIX` - Prefixes shared `APICache` keys with `yt:` so video-source entries do not collide with Music cache invalidation -- Delegates response parsing to YouTube-specific parsers under `Services/API/Parsers/YouTube/` +- Delegates normal response parsing to YouTube-specific parsers under `Services/API/Parsers/YouTube/` +- Routes Ask Gemini through an isolated bounded, same-origin, no-cache, no-automatic-retry transport backed by `YouTubeAskCore`; generic YouTube request behavior is unchanged **Endpoints / surfaces**: - `getHomeBundle()` / `getHomeFeed()` → Recommendations, chips, shelves, and pagination - `search(query:filter:)` → Videos, channels, and playlists -- `getWatchNext(videoId:)` → Watch metadata, related videos, channel state, and comment continuation +- `getWatchNext(videoId:)` → Watch metadata, related videos, channel state, and comment continuation for playback/scrobbling callers +- `getWatchPage(videoId:)` → The same watch data plus an optional strictly parsed Ask bootstrap from one `next` request +- `loadAskConversation(from:)`, `continueAskConversation(_:selecting:)` → Initial Ask panel and exact server-issued chip continuations through `get_panel` - `getComments(continuation:)`, `postComment(...)`, `performCommentAction(...)` → Watch-page comments - `getChannel(channelId:)`, `getPlaylist(playlistId:)`, `getDestinationFeed(_:)`, `getShorts()` → Browse surfaces - `getSubscriptionsFeed()`, `getSubscribedChannels()`, `getHistory(forceRefresh:)`, `getUserPlaylists()` → Signed-in YouTube surfaces - `rateVideo(...)`, `setSubscribed(...)`, `addToWatchLater(...)`, `removeFromWatchLater(...)` → Mutations -See [youtube.md](youtube.md) for the full source-toggle and regular YouTube architecture. +Ask operations require a signed-in primary account. The production app explicitly selects the fixed WEB request profile; strict parser, identity, and transport failures still fail closed per conversation. See [youtube.md](youtube.md) for the product surface, [ADR-0032](adr/0032-youtube-ask-gemini.md) for the activation decision, and the [API discovery record](api-discovery.md#youtube-ask-gemini--youchat-investigation-2026-07-27) for wire-level observations. ### API Parsers @@ -186,7 +194,9 @@ Response parsing is extracted into specialized modules: | `LyricsParser.swift` | Lyrics extraction | | `YouTube/` | Regular YouTube feed, search, watch-next, comments, channel, playlist, guide, and renderer parsers | -**Design**: Static enum-based parsers with pure functions for testability. +`YouTubeAskCore` is a separate Foundation-only target rather than another recursive app parser. It owns the bounded JSON/XSSI/NDJSON/length-prefixed wire decoder, strict YouChat bootstrap/chip/message parser, visible-text sanitizer, opaque command carrier, and direct-chip request builder. The app and API Explorer consume the same package-scoped contract; API Explorer-only reporting and live-action confirmation stay outside the core. + +**Design**: Static enum-based parsers with pure functions for testability. Ask parsing is schema-directed and fail-closed rather than broad recursive discovery. ### Queue Song Metadata and Album Playback @@ -610,6 +620,16 @@ YTMusicClient.getHome() `YouTubeClient` follows the same cookie/SAPISIDHASH flow with `https://www.youtube.com`, the `WEB` client context, and `yt:`-prefixed cache keys. +The optional Ask path begins with the watch page's existing `next` request. A +strict parser may return an account- and video-bound bootstrap; it never treats +HTTP 200 alone as eligibility. Initial preparation and direct server-chip +submission use the Ask-specific transport, whose response collection and frame +sizes are bounded and whose redirects must remain same-origin. Panel calls are +not cached or automatically retried. Only sanitized visible messages leave the +Ask domain layer, while continuations and commands remain opaque in memory. +Production calls are disabled until a request profile passes the read-only +parity gate described in [ADR-0032](adr/0032-youtube-ask-gemini.md). + ## Playback Flow This diagram covers YouTube Music playback. Regular YouTube playback is documented in [youtube.md](youtube.md) and uses `YouTubePlayerService` plus `YouTubeWatchWebView`. @@ -930,6 +950,12 @@ Cancel async work when views disappear or inputs change: } ``` +For Ask Gemini, cancellation is also an identity boundary. The child view model +owns preparation, submission, and New Chat tasks; canceling the watch route +increments its operation generation and discards every opaque command. A late +response must validate the captured video, authentication generation, +primary-account scope, conversation ID, and revision before publishing. + ### Memory Management - **NSCache** for images responds to memory pressure automatically diff --git a/docs/testing.md b/docs/testing.md index b812a1252..c43c2863b 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -12,6 +12,16 @@ This document covers testing strategies, commands, and best practices for Kaset. swift test --skip KasetUITests ``` +### Focused YouTube Ask and Localization Tests + +```bash +swift test --skip KasetUITests --filter YouTubeAsk +swift test --skip KasetUITests --filter LocalizationCatalogParityTests +``` + +These are synthetic, non-UI tests. They do not contact YouTube or submit an Ask +suggestion. + ### Build Only ```bash @@ -39,21 +49,27 @@ swiftlint --strict && swiftformat . ## Test Structure ``` -Tests/KasetTests/ -├── Helpers/ -│ ├── MockURLProtocol.swift # Network mocking -│ ├── MockYTMusicClient.swift # YouTube Music API client mock -│ ├── MockYouTubeClient.swift # Regular YouTube API client mock -│ └── TestFixtures.swift # Fixture loading utilities -├── SwiftTestingHelpers/ -│ └── Tags.swift # Custom test tags (.api, .parser, etc.) -├── Fixtures/ -│ ├── home_response.json # Sample API responses -│ ├── search_response.json -│ ├── playlist_detail.json -│ └── YouTube/ # Sanitized regular YouTube fixtures -├── *Tests.swift # Unit test files (Swift Testing) -└── MusicIntentIntegrationTests.swift # AI integration tests +Tests/ +├── KasetTests/ +│ ├── Helpers/ +│ │ ├── MockURLProtocol.swift # Network mocking +│ │ ├── MockYTMusicClient.swift # YouTube Music API client mock +│ │ ├── MockYouTubeClient.swift # Regular YouTube API client mock +│ │ └── TestFixtures.swift # Fixture loading utilities +│ ├── SwiftTestingHelpers/ +│ │ └── Tags.swift # Custom test tags (.api, .parser, etc.) +│ ├── Fixtures/ +│ │ ├── home_response.json # Sample API responses +│ │ ├── search_response.json +│ │ ├── playlist_detail.json +│ │ └── YouTube/ # Sanitized regular YouTube fixtures +│ ├── YouTubeAskClientTests.swift +│ ├── YouTubeAskViewModelTests.swift +│ ├── *Tests.swift # Other app unit tests (Swift Testing) +│ └── MusicIntentIntegrationTests.swift # Apple Intelligence integration tests +└── YouTubeAskCoreTests/ + ├── Fixtures/ # Small placeholder-only Ask fixtures + └── YouTubeAsk*Tests.swift # Decoder, parser, sanitizer, builder, and fixture safety ``` ## Unit Test Requirements @@ -266,6 +282,37 @@ func parseHomeResponse() { } ``` +### YouTube Ask Tests + +YouTube Ask has two test layers: + +1. `YouTubeAskCoreTests` proves the Foundation-only boundary: bounded JSON, + XSSI, NDJSON, and length-prefixed decoding; strict YouChat ancestry and chip + extraction; decoy and unsupported-decorator rejection; server-order + preservation; visible-text sanitization; exact direct-chip request bodies; + and redacted opaque-command behavior. +2. `KasetTests` covers `YouTubeClient` and view-model integration: one shared + watch `next` request, signed-in primary-account gating, exact `get_panel` + URL/body, forbidden-field absence, monotonic message IDs, no cache or retry, + same-origin redirects, HTTP/error mapping, identity-generation fences, lazy + preparation, single-flight submission, transactional New Chat, cancellation, + and prevention of command reuse. + +Fixtures must be small, hand-authored, and visibly synthetic. Use placeholder +values such as `fixture-video-a` and `fixture-continuation-a`; never copy cookies, +authorization proofs, API keys, account identifiers, personalized payloads, or +real opaque values into source or test output. Fixture safety tests report only +file, JSON path, rule, and value length—never the rejected value. + +Run the complete non-UI Ask slice with: + +```bash +swift test --skip KasetUITests --filter YouTubeAsk +``` + +The UI-test fixture may model eligible, ineligible, and error states, but UI tests +launch the app and require explicit human approval before execution. + ### Parameterized Tests Test multiple inputs efficiently: @@ -320,7 +367,7 @@ final class MockYouTubeClient: YouTubeClientProtocol, @unchecked Sendable { } ``` -Regular YouTube parser tests should use sanitized fixtures in `Tests/KasetTests/Fixtures/YouTube/`; re-capture with `swift run api-explorer --youtube ... -o` when YouTube renderer shapes change. +Regular YouTube parser tests should use sanitized fixtures in `Tests/KasetTests/Fixtures/YouTube/`; re-capture with `swift run api-explorer --youtube ... -o` when YouTube renderer shapes change. Ask fixtures are different: keep them hand-authored and placeholder-only instead of capturing personalized YouChat responses. **Usage in tests**: ```swift @@ -358,6 +405,20 @@ let data = TestFixtures.loadJSON("home_response") // Loads home_response.json let dict = TestFixtures.loadJSONDict("search_response") ``` +### Localization Catalog and Mirrors + +`Sources/Kaset/Resources/Localizable.xcstrings` is the source of truth and every +shipped `.lproj/Localizable.strings` mirror must be updated in the same change. +Run: + +```bash +swift test --skip KasetUITests --filter LocalizationCatalogParityTests +``` + +For Ask Gemini, localize only UI-owned chrome, disclosure, progress, error, and +accessibility strings. Server-issued suggestion labels and generated answers are +shown as sanitized verbatim text and must not become localization keys. + ## Accessibility Testing ### VoiceOver @@ -383,6 +444,20 @@ Button { ## Integration Testing +### YouTube Ask Compatibility Validation + +YouTube Ask unit tests are deterministic and offline. The API Explorer parity +workflow is a separate, read-only manual compatibility check: it may send +`next` and prepare the initial panel, but it must never submit a suggestion or +free-form prompt. Live answer generation requires separate explicit approval and +is not part of routine tests or CI. + +The production app explicitly selects the fixed WEB request profile. The July 28, +2026 parity run was inconclusive because the exported session appeared signed out; +keep recording all request/response evidence in +[api-discovery.md](api-discovery.md#youtube-ask-gemini--youchat-investigation-2026-07-27) +and keep the activation rule in [ADR-0032](adr/0032-youtube-ask-gemini.md). + ### AI Integration Tests (Apple Intelligence) The `MusicIntentIntegrationTests` suite validates LLM parsing of natural language commands into `MusicIntent` structs. @@ -462,6 +537,7 @@ Before releasing: - [ ] Media keys work - [ ] Re-opening window doesn't duplicate audio - [ ] Sign out and re-login works +- [ ] Ask Gemini remains absent while no request profile has passed parity ### Simulating Auth Expiry (Runtime Debugging) diff --git a/docs/youtube.md b/docs/youtube.md index 2fccde573..090b60eeb 100644 --- a/docs/youtube.md +++ b/docs/youtube.md @@ -19,6 +19,13 @@ and [playback.md](playback.md). - **Source switches preserve state.** Toggling to Music pauses a docked video in place and keeps the YouTube navigation intact for restore; music keeps playing while browsing YouTube until a video starts. +- **Product data uses APIs, not the playback WebView.** The regular YouTube + WebView remains responsible for DRM playback. Optional watch-page features + such as Ask Gemini must use `YouTubeClient`, strict response parsing, and + account/lifecycle fences rather than DOM automation. +- **Undocumented surfaces fail closed.** HTTP success is not enough to enable a + rollout-fragile feature. Ask Gemini remains hidden until a redacted parity run + proves an eligible signed-in primary-account request profile. ## Layer Map @@ -28,6 +35,7 @@ and [playback.md](playback.md). | Protocol | `YTMusicClientProtocol` | `YouTubeClientProtocol` | | Models | Song/Album/Artist/Playlist | `YouTubeVideo`/`YouTubeChannel`/`YouTubePlaylist` | | Parsers | `Services/API/Parsers/` | `Services/API/Parsers/YouTube/` | +| Ask safety core | — | `YouTubeAskCore` (Foundation-only wire decoding, strict parsing, sanitization, and request bodies) | | Playback WebView | `SingletonPlayerWebView` | `YouTubeWatchWebView` | | Player service | `PlayerService` | `YouTubePlayerService` | | Floating window | `VideoWindowController` | `YouTubeVideoWindowController` | @@ -51,6 +59,14 @@ controls music while browsing YouTube), shared view components, and - **No API key**: the `key=` query parameter is no longer required by InnerTube (confirmed June 2026). +Ask Gemini is an isolated optional path rather than a change to generic YouTube +requests. `getWatchPage(videoId:)` parses normal watch data and an optional Ask +bootstrap from the same `next` response. High-level conversation operations use +an Ask-specific bounded, same-origin, no-cache, no-automatic-retry transport and +expose only sanitized text plus local IDs to UI code. Opaque server commands stay +inside memory-bound domain values. See +[ADR-0032](adr/0032-youtube-ask-gemini.md). + ### Endpoints | Surface | Request | @@ -65,6 +81,8 @@ controls music while browsing YouTube), shared view components, and | Search | `search` (+`params` filters: videos `EgIQAQ==`, channels `EgIQAg==`, playlists `EgIQAw==`) | | Watch metadata + related | `next` | | Watch chapters | `next` (`playerOverlays…multiMarkersPlayerBarRenderer.markersMap[].value.chapters[]`) | +| Ask eligibility/bootstrap | The existing watch `next` response, parsed only from confirmed YouChat structures | +| Ask panel + server-issued chips | `get_panel` using the explicitly selected fixed WEB request profile | | Like / unlike | `like/like`, `like/dislike`, `like/removelike` | | Subscribe | `subscription/subscribe` / `subscription/unsubscribe` | | Watch Later edit | `browse/edit_playlist` (playlistId `WL`) | @@ -86,6 +104,21 @@ Use `swift run api-explorer --youtube browse ` to inspect live responses — the renderer histogram in its output shows what a surface currently serves. +### Ask Gemini Activation Gate + +The Ask implementation is intentionally chips-only: expanding the collapsed +card may prepare an initial panel, but it never generates an answer until the +user selects a server-issued suggestion. Follow-up chips are also server-issued; +there is no free-text composer or `streaming_panel` path. Visible labels and +answers are sanitized but not localized by Kaset. + +Production activation is currently disabled because no request profile has +passed authenticated eligibility validation, so the client must continue to +omit the panel. The +[API discovery record](api-discovery.md#youtube-ask-gemini--youchat-investigation-2026-07-27) +is the wire-level source of truth; this document records only the product and +architecture boundary. See also [ADR-0032](adr/0032-youtube-ask-gemini.md). + ## Player Bar The bottom Liquid Glass bar adapts to the active source. In YouTube mode @@ -195,6 +228,15 @@ natively. Comments come from the watch page's `comment-item-section` continuatio `comment/perform_comment_action` (like/unlike/dislike/undislike action tokens), expandable reply threads, and author → channel navigation. +When enabled by a validated request profile and an eligible watch response, Ask +Gemini appears as a collapsed card above Related. It discloses that YouTube +generates the responses, prepares lazily, shows a height-bounded selectable +transcript, disables all chips during a single in-flight request, and offers New +Chat after the first turn or when a submission outcome is uncertain. The +conversation is owned by the current watch view—not `YouTubeViewModelStore`—and +is discarded on navigation, source/account/authentication changes, cancellation, +or app termination. + ### Ads Kaset does not block ads. During an ad, `STATE_UPDATE.isAd` is true and @@ -212,9 +254,27 @@ the native scrubber is disabled; YouTube Premium accounts see no ads. so playback state tests never create WebViews. - `InnerTubeSupportTests` pins SAPISIDHASH vectors for both origins — if those fail, auth is broken app-wide. +- `YouTubeAskCoreTests` use small placeholder-only fixtures to cover bounded wire + formats, strict YouChat parsing, decoy rejection, server order, sanitization, + and accidental-secret detection. `YouTubeAskTransportTests`, + `YouTubeAskClientTests`, and `YouTubeAskViewModelTests` cover redirect and + response bounds, exact request shapes, identity fencing, single-flight + behavior, New Chat, and command consumption without contacting YouTube. +- Ask UI tests are never part of routine verification because they launch the + app. Run them only after explicit human approval. The read-only + `ask-video-parity` command is a manual compatibility check, not a unit test and + not evidence of a passing profile unless signed-in eligibility is confirmed. ## Known Limitations / Future Work +- Ask Gemini production uses the fixed WEB request profile selected explicitly + by the app. Eligibility remains dependent on YouTube returning a supported + YouChat bootstrap for the signed-in primary account and current video. See + [ADR-0032](adr/0032-youtube-ask-gemini.md) and the + [API discovery record](api-discovery.md#youtube-ask-gemini--youchat-investigation-2026-07-27). +- Ask Gemini v1 intentionally omits free-form prompts, `streaming_panel`, brand + accounts, persisted conversations, telemetry, clickable generated links, and + Apple Intelligence dependencies. - No auto-advance to the next related video after `VIDEO_ENDED` (YouTube autonav is disabled; Kaset shows the ended state — the bar's next button advances manually). From 8ed1b6fc3e998bc50237c65ea2f14fb7dfdc72d0 Mon Sep 17 00:00:00 2001 From: Sertac Ozercan Date: Fri, 31 Jul 2026 19:41:07 -0700 Subject: [PATCH 05/18] fix(youtube): accept current Ask chip bootstrap Signed-off-by: Sertac Ozercan --- Sources/YouTubeAskCore/YouTubeAskParser.swift | 130 ++++++++++++-- .../YouTubeAskParserTests.swift | 160 ++++++++++++++++++ docs/adr/0032-youtube-ask-gemini.md | 16 +- docs/api-discovery.md | 16 ++ docs/testing.md | 6 +- docs/youtube.md | 9 +- 6 files changed, 314 insertions(+), 23 deletions(-) diff --git a/Sources/YouTubeAskCore/YouTubeAskParser.swift b/Sources/YouTubeAskCore/YouTubeAskParser.swift index 560b8373c..88f1602aa 100644 --- a/Sources/YouTubeAskCore/YouTubeAskParser.swift +++ b/Sources/YouTubeAskCore/YouTubeAskParser.swift @@ -39,7 +39,15 @@ package enum YouTubeAskParser { ) } - let panelContinuation = try Self.unambiguousContinuation(continuationCandidates) + let panelContinuation: String? + do { + panelContinuation = try Self.unambiguousContinuation(continuationCandidates) + } catch YouTubeAskCoreError.ambiguousBootstrap where !content.suggestions.isEmpty { + // Direct chip continuations are independently validated capabilities. + // Do not guess among unrelated panel-bootstrap commands when the + // panel can already be presented without materialization. + panelContinuation = nil + } guard panelContinuation != nil || !content.suggestions.isEmpty else { return nil } return YouTubeAskParsedBootstrap( panelCommand: panelContinuation.map(YouTubeAskOpaqueCommand.init), @@ -416,13 +424,8 @@ package enum YouTubeAskParser { private static func parseChip( _ value: YouTubeAskJSONValue ) throws -> YouTubeAskParsedSuggestion { - guard let chip = value.objectValue else { - throw YouTubeAskCoreError.malformedChip - } - if Self.containsUnsupportedChipDecorator(value) { - throw YouTubeAskCoreError.unsupportedChipDecorator - } - guard let continuation = chip["continuation"]?.stringValue, + guard let chip = value.objectValue, + let continuation = chip["continuation"]?.stringValue, !continuation.isEmpty, continuation.count <= YouTubeAskLimits.maximumCommandCharacters, let textValue = chip["text"] @@ -434,6 +437,12 @@ package enum YouTubeAskParser { from: textValue, malformedError: .malformedChip ) + if Self.containsUnsupportedChipDecorator( + value, + expectedVisibleText: visibleText + ) { + throw YouTubeAskCoreError.unsupportedChipDecorator + } guard let label = YouTubeAskVisibleTextSanitizer.sanitizeChipLabel(visibleText) else { throw YouTubeAskCoreError.malformedChip } @@ -507,14 +516,23 @@ package enum YouTubeAskParser { } private static func containsUnsupportedChipDecorator( - _ value: YouTubeAskJSONValue + _ value: YouTubeAskJSONValue, + expectedVisibleText: String ) -> Bool { switch value { case let .object(object): for (key, nested) in object { let canonical = key.lowercased() - if canonical == "onclick" - || canonical == "innertubecommand" + if canonical == "onclick" { + guard Self.isSupportedOnClickDecorator( + nested, + expectedVisibleText: expectedVisibleText + ) else { + return true + } + continue + } + if canonical == "innertubecommand" || canonical == "commandmetadata" || canonical.hasSuffix("command") || canonical.hasSuffix("endpoint") @@ -522,15 +540,101 @@ package enum YouTubeAskParser { { return true } - if Self.containsUnsupportedChipDecorator(nested) { + if Self.containsUnsupportedChipDecorator( + nested, + expectedVisibleText: expectedVisibleText + ) { return true } } return false case let .array(array): - return array.contains(where: Self.containsUnsupportedChipDecorator) + return array.contains { nested in + Self.containsUnsupportedChipDecorator( + nested, + expectedVisibleText: expectedVisibleText + ) + } default: return false } } + + /// Current YouChat chips may carry a local `listMutationCommand` that + /// inserts the already-visible chip label as the pending user turn and + /// scrolls the panel. Kaset performs those UI updates itself and never + /// executes or preserves this callback. Accept only the exact observed + /// schema and reject every other command or field. + private static func isSupportedOnClickDecorator( + _ value: YouTubeAskJSONValue, + expectedVisibleText: String + ) -> Bool { + guard let onClick = value.objectValue, + Set(onClick.keys) == ["clickTrackingParams", "listMutationCommand"], + hasNonemptyString(onClick["clickTrackingParams"]), + let listMutation = onClick["listMutationCommand"]?.objectValue, + Set(listMutation.keys) == ["operations"], + let mutationOperations = listMutation["operations"]?.objectValue, + Set(mutationOperations.keys) == ["operations", "scrollConfig"], + let operations = mutationOperations["operations"]?.arrayValue, + operations.count == 1, + isSupportedInsertOperation( + operations[0], + expectedVisibleText: expectedVisibleText + ), + isSupportedScrollConfig(mutationOperations["scrollConfig"]) + else { + return false + } + return true + } + + private static func isSupportedInsertOperation( + _ value: YouTubeAskJSONValue, + expectedVisibleText: String + ) -> Bool { + guard let operation = value.objectValue, + Set(operation.keys) == ["insertItemSectionContent"], + let insertion = operation["insertItemSectionContent"]?.objectValue, + Set(insertion.keys) == ["contents", "insertByPositionInSection"], + let contents = insertion["contents"]?.arrayValue, + contents.count == 1, + let content = contents[0].objectValue, + Set(content.keys) == ["chatUserTurnViewModel"], + let userTurn = content["chatUserTurnViewModel"]?.objectValue, + Set(userTurn.keys) == ["backgroundStyle", "text"], + hasNonemptyString(userTurn["backgroundStyle"]), + userTurn["text"]?.stringValue == expectedVisibleText, + let position = insertion["insertByPositionInSection"]?.objectValue, + Set(position.keys) == ["position", "sectionTargetId"], + hasNonemptyString(position["position"]), + hasNonemptyString(position["sectionTargetId"]) + else { + return false + } + return true + } + + private static func isSupportedScrollConfig( + _ value: YouTubeAskJSONValue? + ) -> Bool { + guard let scrollConfig = value?.objectValue, + Set(scrollConfig.keys) == ["scrollToItem"], + let scrollToItem = scrollConfig["scrollToItem"]?.objectValue, + Set(scrollToItem.keys) == ["item", "scrollPosition"], + hasNonemptyString(scrollToItem["scrollPosition"]), + let item = scrollToItem["item"]?.objectValue, + Set(item.keys) == ["itemTargetId", "sectionTargetId"], + hasNonemptyString(item["itemTargetId"]), + hasNonemptyString(item["sectionTargetId"]) + else { + return false + } + return true + } + + private static func hasNonemptyString(_ value: YouTubeAskJSONValue?) -> Bool { + guard let string = value?.stringValue else { return false } + return !string.isEmpty + } } diff --git a/Tests/YouTubeAskCoreTests/YouTubeAskParserTests.swift b/Tests/YouTubeAskCoreTests/YouTubeAskParserTests.swift index f7ebe4be0..f204b9e30 100644 --- a/Tests/YouTubeAskCoreTests/YouTubeAskParserTests.swift +++ b/Tests/YouTubeAskCoreTests/YouTubeAskParserTests.swift @@ -77,6 +77,45 @@ struct YouTubeAskParserTests { #expect(bootstrap.panelCommand?.continuation == "fixture-panel-continuation-a") } + @Test("Keeps direct chips when panel bootstrap commands are ambiguous") + func directChipsSurvivePanelCommandAmbiguity() throws { + let envelope = try Self.envelope([ + "engagementPanels": [[ + "panelIdentifier": "PAyouchat", + "commands": [ + [ + "continuationCommand": [ + "request": "CONTINUATION_REQUEST_TYPE_GET_PANEL", + "token": "fixture-panel-continuation-a", + ], + ], + [ + "continuationCommand": [ + "request": "CONTINUATION_REQUEST_TYPE_GET_PANEL", + "token": "fixture-panel-continuation-b", + ], + ], + ], + "content": [ + "youChatItemViewModel": [ + "chipsData": [ + "chipData": [[ + "text": ["simpleText": "Direct suggestion"], + "continuation": "fixture-direct-continuation", + ]], + ], + ], + ], + ]], + ]) + + let parsedBootstrap = try YouTubeAskParser.parseBootstrap(from: envelope) + let bootstrap = try #require(parsedBootstrap) + #expect(bootstrap.panelCommand == nil) + #expect(bootstrap.suggestions.map(\.label) == ["Direct suggestion"]) + #expect(bootstrap.suggestions.first?.command.continuation == "fixture-direct-continuation") + } + @Test("Extracts chips only from supported YouChat continuation-item containers") func strictChipPath() throws { let decoy = [ @@ -136,6 +175,33 @@ struct YouTubeAskParserTests { ]) } + @Test("Accepts the observed local list-mutation callback without executing it") + func acceptsObservedOnClickListMutation() throws { + let envelope = try Self.envelope([ + "engagementPanels": [[ + "panelIdentifier": "PAyouchat", + "content": [ + "youChatItemViewModel": [ + "chipsData": [ + "chipData": [[ + "text": ["simpleText": "Supported suggestion"], + "continuation": "fixture-supported-continuation", + "onClick": Self.localListMutationCallback( + visibleText: "Supported suggestion" + ), + ]], + ], + ], + ], + ]], + ]) + + let parsedBootstrap = try YouTubeAskParser.parseBootstrap(from: envelope) + let bootstrap = try #require(parsedBootstrap) + #expect(bootstrap.suggestions.map(\.label) == ["Supported suggestion"]) + #expect(bootstrap.suggestions.first?.command.continuation == "fixture-supported-continuation") + } + @Test("Fails the entire chip set on malformed or decorated chips") func malformedChipsFailClosed() throws { let decorated = try Self.conversationEnvelope(chips: [[ @@ -149,6 +215,65 @@ struct YouTubeAskParserTests { _ = try YouTubeAskParser.parseConversation(from: decorated) } + let alternativeContinuation = try Self.conversationEnvelope(chips: [[ + "text": ["simpleText": "Alternative command"], + "continuation": "fixture-root-continuation", + "onClick": [ + "continuationCommand": [ + "request": "CONTINUATION_REQUEST_TYPE_GET_PANEL", + "token": "fixture-alternative-continuation", + ], + ], + ]]) + expectYouTubeAskError(.unsupportedChipDecorator) { + _ = try YouTubeAskParser.parseConversation(from: alternativeContinuation) + } + + let malformedOnClick = try Self.conversationEnvelope(chips: [[ + "text": ["simpleText": "Malformed callback"], + "continuation": "fixture-malformed-callback-continuation", + "onClick": "unsupported-callback", + ]]) + expectYouTubeAskError(.unsupportedChipDecorator) { + _ = try YouTubeAskParser.parseConversation(from: malformedOnClick) + } + + let multipleCallbackCommands = try Self.conversationEnvelope(chips: [[ + "text": ["simpleText": "Multiple callbacks"], + "continuation": "fixture-multiple-callback-continuation", + "onClick": [ + "recordClickCommand": ["placeholder": true], + "recordVisibilityCommand": ["placeholder": true], + ], + ]]) + expectYouTubeAskError(.unsupportedChipDecorator) { + _ = try YouTubeAskParser.parseConversation(from: multipleCallbackCommands) + } + + let unknownCommand = try Self.conversationEnvelope(chips: [[ + "text": ["simpleText": "Unknown callback"], + "continuation": "fixture-unknown-callback-continuation", + "onClick": [ + "loadContinuationCommand": [ + "continuationToken": "fixture-hidden-continuation", + ], + ], + ]]) + expectYouTubeAskError(.unsupportedChipDecorator) { + _ = try YouTubeAskParser.parseConversation(from: unknownCommand) + } + + let mismatchedVisibleText = try Self.conversationEnvelope(chips: [[ + "text": ["simpleText": "Visible suggestion"], + "continuation": "fixture-mismatched-callback-continuation", + "onClick": Self.localListMutationCallback( + visibleText: "Different suggestion" + ), + ]]) + expectYouTubeAskError(.unsupportedChipDecorator) { + _ = try YouTubeAskParser.parseConversation(from: mismatchedVisibleText) + } + let missingContinuation = try Self.conversationEnvelope(chips: [[ "text": ["simpleText": "Missing command"], ]]) @@ -344,6 +469,41 @@ struct YouTubeAskParserTests { } } + private static func localListMutationCallback( + visibleText: String + ) -> [String: Any] { + [ + "clickTrackingParams": "fixture-tracking-placeholder", + "listMutationCommand": [ + "operations": [ + "operations": [[ + "insertItemSectionContent": [ + "contents": [[ + "chatUserTurnViewModel": [ + "backgroundStyle": "CHAT_USER_TURN_BACKGROUND_STYLE_DEFAULT", + "text": visibleText, + ], + ]], + "insertByPositionInSection": [ + "position": "ITEM_SECTION_POSITION_END", + "sectionTargetId": "fixture-section-target", + ], + ], + ]], + "scrollConfig": [ + "scrollToItem": [ + "item": [ + "itemTargetId": "fixture-item-target", + "sectionTargetId": "fixture-section-target", + ], + "scrollPosition": "SCROLL_POSITION_BOTTOM", + ], + ], + ], + ], + ] + } + private static func bootstrapObject(tokens: [String]) -> [String: Any] { [ "engagementPanels": [[ diff --git a/docs/adr/0032-youtube-ask-gemini.md b/docs/adr/0032-youtube-ask-gemini.md index e9846629a..2ad9014df 100644 --- a/docs/adr/0032-youtube-ask-gemini.md +++ b/docs/adr/0032-youtube-ask-gemini.md @@ -59,11 +59,17 @@ Wire-level observations and the API Explorer workflow remain documented in the displayed verbatim and are not localized by Kaset. 5. **Fail closed on unsupported or ambiguous data.** Strict parsing recognizes only confirmed YouChat structures, bounded wire formats, and supported - message/chip containers. Ambiguity, malformed or oversized responses, - unsupported decorators, identity changes, or uncertain submission outcomes - disable the current session. There is no automatic retry; the UI may offer - New Chat, which starts from a fresh watch bootstrap and replaces the old - conversation only after preparation succeeds. + message/chip containers. A chip may carry the exact observed `onClick.listMutationCommand` UI + mutation, which is ignored rather than preserved or executed only when its + inserted user-turn text matches the chip label and every key matches the + allowlisted local insertion/scroll shape. Any other callback rejects the chip + set. Multiple panel-bootstrap commands reject the + bootstrap unless validated direct chips make panel materialization unnecessary, + in which case Kaset discards every ambiguous panel command. Other ambiguity, + malformed or oversized responses, unsupported decorators, identity changes, + or uncertain submission outcomes disable the current session. There is no + automatic retry; the UI may offer New Chat, which starts from a fresh watch + bootstrap and replaces the old conversation only after preparation succeeds. 6. **Select the production request profile explicitly.** The production app selects the fixed WEB profile. The July 28, 2026 parity run was inconclusive because the exported session appeared signed out; activation was subsequently diff --git a/docs/api-discovery.md b/docs/api-discovery.md index d2ba175e4..4ca6639bb 100644 --- a/docs/api-discovery.md +++ b/docs/api-discovery.md @@ -1483,6 +1483,21 @@ inventing them. The chip's `id`, visible text, click-tracking command, and the free-text composer's `sendUserQueryCommand` are not copied into this direct-chip request. +**Signed-in production compatibility check on August 1, 2026**: + +- The authenticated watch response exposed validated direct chips whose + `chipData` entries could include a top-level `onClick` callback containing one + local interaction command. +- The root `chipData.continuation` remained the only replayed capability. Kaset + ignores the callback only when it matches the observed `listMutationCommand` structure, inserts the same + visible label as the chip, and contains no extra keys or request capability. +- The same response exposed multiple distinct panel-bootstrap continuations. + When direct chips are already present, Kaset discards the ambiguous panel + command instead of guessing; ambiguity still rejects bootstraps that have no + direct chips. +- A read-only production-client probe then parsed the watch bootstrap + successfully. No panel materialization or suggestion submission was performed. + The free-text composer is a different path. It uses the server-issued `sendUserQueryCommand` (or its own fallback continuation), adds `userInputText`, and selects `streaming_panel` only when command metadata explicitly names that @@ -1586,6 +1601,7 @@ The `--brand` flag sets `context.user.onBehalfOfUser` in the request body. See [ | Date | Changes | |------|---------| +| 2026-08-01 | Revalidated an eligible signed-in production watch response; added strict support for inert chip `onClick` callbacks and preserved direct chips while discarding ambiguous panel-only commands | | 2026-07-30 | Enabled the fixed WEB Ask request profile in the production app by explicit product direction; eligibility and all strict parser, identity, and transport gates remain enforced | | 2026-07-28 | Added redacted read-only `ask-video-parity` tooling backed by `YouTubeAskCore`; all three profiles returned HTTP 200 `next` responses but the exported session was treated as signed out, so no profile passed and production remains disabled | | 2026-07-27 | Live-validated YouTube Ask Gemini / YouChat summary, follow-up, and two fresh chats; added guarded `ask-video-live-test`, corrected direct chips to `get_panel`, retained read-only `ask-video-audit`, and documented redaction/auth constraints | diff --git a/docs/testing.md b/docs/testing.md index c43c2863b..a5b21be8e 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -288,8 +288,10 @@ YouTube Ask has two test layers: 1. `YouTubeAskCoreTests` proves the Foundation-only boundary: bounded JSON, XSSI, NDJSON, and length-prefixed decoding; strict YouChat ancestry and chip - extraction; decoy and unsupported-decorator rejection; server-order - preservation; visible-text sanitization; exact direct-chip request bodies; + extraction; decoy and unsupported-decorator rejection; exact `onClick.listMutationCommand` + callback acceptance without execution and unknown-command rejection; direct-chip preservation when panel + commands are ambiguous; server-order preservation; visible-text sanitization; + exact direct-chip request bodies; and redacted opaque-command behavior. 2. `KasetTests` covers `YouTubeClient` and view-model integration: one shared watch `next` request, signed-in primary-account gating, exact `get_panel` diff --git a/docs/youtube.md b/docs/youtube.md index 090b60eeb..dbc5c50e4 100644 --- a/docs/youtube.md +++ b/docs/youtube.md @@ -112,9 +112,12 @@ user selects a server-issued suggestion. Follow-up chips are also server-issued; there is no free-text composer or `streaming_panel` path. Visible labels and answers are sanitized but not localized by Kaset. -Production activation is currently disabled because no request profile has -passed authenticated eligibility validation, so the client must continue to -omit the panel. The +Production activation uses the fixed WEB request profile selected on July 30, +2026. Eligibility remains account- and video-scoped: signed-out, guest, brand, +identity-mismatched, malformed, and unsupported responses omit the panel. A +validated direct chip remains usable even when unrelated panel-bootstrap +continuations are ambiguous; Kaset never guesses or replays those panel commands. +The [API discovery record](api-discovery.md#youtube-ask-gemini--youchat-investigation-2026-07-27) is the wire-level source of truth; this document records only the product and architecture boundary. See also [ADR-0032](adr/0032-youtube-ask-gemini.md). From 6521e69f45c7104dfd4a7c731013b4810c71fff0 Mon Sep 17 00:00:00 2001 From: Sertac Ozercan Date: Fri, 31 Jul 2026 20:48:12 -0700 Subject: [PATCH 06/18] fix(youtube): recover Ask watch bootstrap Signed-off-by: Sertac Ozercan --- .../YouTube/YouTubeWatchViewModel.swift | 23 ++++- Sources/YouTubeAskCore/YouTubeAskParser.swift | 88 +++++++++++++++++-- .../Helpers/MockYouTubeClient.swift | 5 ++ .../KasetTests/YouTubeAskViewModelTests.swift | 45 ++++++++++ .../YouTubeAskParserTests.swift | 51 +++++++++-- docs/adr/0032-youtube-ask-gemini.md | 18 ++-- docs/api-discovery.md | 7 +- docs/testing.md | 12 +-- docs/youtube.md | 4 +- 9 files changed, 222 insertions(+), 31 deletions(-) diff --git a/Sources/Kaset/ViewModels/YouTube/YouTubeWatchViewModel.swift b/Sources/Kaset/ViewModels/YouTube/YouTubeWatchViewModel.swift index 54ae6278b..662bb79bc 100644 --- a/Sources/Kaset/ViewModels/YouTube/YouTubeWatchViewModel.swift +++ b/Sources/Kaset/ViewModels/YouTube/YouTubeWatchViewModel.swift @@ -124,7 +124,7 @@ final class YouTubeWatchViewModel { self.ask.cancelAndDiscard() self.loadingState = .loading do { - let page = try await self.client.getWatchPage(videoId: self.video.videoId) + let page = try await self.loadWatchPageWithIdentityRetry(generation: generation) guard generation == self.loadGeneration else { return } self.data = page.data self.isSubscribed = page.data.isSubscribed ?? false @@ -146,6 +146,27 @@ final class YouTubeWatchViewModel { } } + /// A scope publication can reset the shared client after a watch response + /// parses but before its final identity fence. Retry that read-only `next` + /// request once when the outer route task and load generation are still + /// current. Panel materialization and suggestion submission are never retried. + private func loadWatchPageWithIdentityRetry( + generation: Int + ) async throws -> YouTubeWatchPage { + do { + return try await self.client.getWatchPage(videoId: self.video.videoId) + } catch is CancellationError { + guard !Task.isCancelled, generation == self.loadGeneration else { + throw CancellationError() + } + await Task.yield() + guard !Task.isCancelled, generation == self.loadGeneration else { + throw CancellationError() + } + return try await self.client.getWatchPage(videoId: self.video.videoId) + } + } + /// Invalidates the current route load and discards all Ask state. func cancel() { self.loadGeneration += 1 diff --git a/Sources/YouTubeAskCore/YouTubeAskParser.swift b/Sources/YouTubeAskCore/YouTubeAskParser.swift index 88f1602aa..580262b1c 100644 --- a/Sources/YouTubeAskCore/YouTubeAskParser.swift +++ b/Sources/YouTubeAskCore/YouTubeAskParser.swift @@ -598,13 +598,19 @@ package enum YouTubeAskParser { let insertion = operation["insertItemSectionContent"]?.objectValue, Set(insertion.keys) == ["contents", "insertByPositionInSection"], let contents = insertion["contents"]?.arrayValue, - contents.count == 1, - let content = contents[0].objectValue, - Set(content.keys) == ["chatUserTurnViewModel"], - let userTurn = content["chatUserTurnViewModel"]?.objectValue, - Set(userTurn.keys) == ["backgroundStyle", "text"], - hasNonemptyString(userTurn["backgroundStyle"]), - userTurn["text"]?.stringValue == expectedVisibleText, + contents.count == 2, + contents.count(where: { content in + content.objectValue.map { Set($0.keys) == ["chatUserTurnViewModel"] } ?? false + }) == 1, + contents.count(where: { content in + content.objectValue.map { Set($0.keys) == ["chatLoadingViewModel"] } ?? false + }) == 1, + contents.allSatisfy({ content in + Self.isSupportedUserTurnContent( + content, + expectedVisibleText: expectedVisibleText + ) || Self.isSupportedLoadingContent(content) + }), let position = insertion["insertByPositionInSection"]?.objectValue, Set(position.keys) == ["position", "sectionTargetId"], hasNonemptyString(position["position"]), @@ -615,6 +621,74 @@ package enum YouTubeAskParser { return true } + private static func isSupportedUserTurnContent( + _ value: YouTubeAskJSONValue, + expectedVisibleText: String + ) -> Bool { + guard let content = value.objectValue, + Set(content.keys) == ["chatUserTurnViewModel"], + let userTurn = content["chatUserTurnViewModel"]?.objectValue, + Set(userTurn.keys) == ["backgroundStyle", "text"], + hasNonemptyString(userTurn["backgroundStyle"]), + userTurn["text"]?.stringValue == expectedVisibleText + else { + return false + } + return true + } + + private static func isSupportedLoadingContent( + _ value: YouTubeAskJSONValue + ) -> Bool { + guard let content = value.objectValue, + Set(content.keys) == ["chatLoadingViewModel"], + let loading = content["chatLoadingViewModel"]?.objectValue, + Set(loading.keys) == [ + "animation", + "darkThemeAnimation", + "loadingAnimationA11yLabel", + "targetId", + ], + hasNonemptyString(loading["loadingAnimationA11yLabel"]), + hasNonemptyString(loading["targetId"]), + isSupportedLoadingAnimation(loading["animation"]), + isSupportedLoadingAnimation(loading["darkThemeAnimation"]) + else { + return false + } + return true + } + + private static func isSupportedLoadingAnimation( + _ value: YouTubeAskJSONValue? + ) -> Bool { + guard let animation = value?.objectValue, + Set(animation.keys) == ["lottieAnimationViewModel"], + let lottie = animation["lottieAnimationViewModel"]?.objectValue, + Set(lottie.keys) == ["loop", "trustedAnimationUrl"], + isBooleanOrNumber(lottie["loop"]), + let trustedURL = lottie["trustedAnimationUrl"]?.objectValue, + Set(trustedURL.keys) == [ + "privateDoNotAccessOrElseTrustedResourceUrlWrappedValue", + ], + hasNonemptyString( + trustedURL["privateDoNotAccessOrElseTrustedResourceUrlWrappedValue"] + ) + else { + return false + } + return true + } + + private static func isBooleanOrNumber(_ value: YouTubeAskJSONValue?) -> Bool { + switch value { + case .bool, .number: + true + default: + false + } + } + private static func isSupportedScrollConfig( _ value: YouTubeAskJSONValue? ) -> Bool { diff --git a/Tests/KasetTests/Helpers/MockYouTubeClient.swift b/Tests/KasetTests/Helpers/MockYouTubeClient.swift index 809fa1939..b162e3790 100644 --- a/Tests/KasetTests/Helpers/MockYouTubeClient.swift +++ b/Tests/KasetTests/Helpers/MockYouTubeClient.swift @@ -22,6 +22,7 @@ final class MockYouTubeClient: YouTubeClientProtocol { var watchNextData = WatchNextData.empty var askBootstrap: YouTubeAskBootstrap? var watchPages: [YouTubeWatchPage] = [] + var watchPageErrors: [Error] = [] var askConversation = YouTubeAskConversation.testing() var continuedAskConversation: YouTubeAskConversation? var askError: Error? @@ -58,6 +59,7 @@ final class MockYouTubeClient: YouTubeClientProtocol { self.searchContinuation = nil self.askBootstrap = nil self.watchPages = [] + self.watchPageErrors = [] self.askConversation = YouTubeAskConversation.testing() self.continuedAskConversation = nil } @@ -236,6 +238,9 @@ final class MockYouTubeClient: YouTubeClientProtocol { func getWatchPage(videoId _: String) async throws -> YouTubeWatchPage { self.getWatchPageCallCount += 1 + if !self.watchPageErrors.isEmpty { + throw self.watchPageErrors.removeFirst() + } if let error { throw error } diff --git a/Tests/KasetTests/YouTubeAskViewModelTests.swift b/Tests/KasetTests/YouTubeAskViewModelTests.swift index 0b41268a0..e8c621d2f 100644 --- a/Tests/KasetTests/YouTubeAskViewModelTests.swift +++ b/Tests/KasetTests/YouTubeAskViewModelTests.swift @@ -181,6 +181,51 @@ struct YouTubeAskViewModelTests { #expect(sut.ask.suggestions.map(\.text) == ["Continue"]) } + @Test("An internal identity cancellation retries the read-only watch page once") + func internalIdentityCancellationRetriesWatchPage() async { + let client = MockYouTubeClient() + client.watchPageErrors = [CancellationError()] + client.watchPages = [YouTubeWatchPage( + data: .empty, + askBootstrap: YouTubeAskBootstrap.testing(suggestions: ["Recovered question"]) + )] + let video = MockYouTubeClient.makeVideo(videoId: "fixture-video") + let sut = YouTubeWatchViewModel(video: video, client: client) + + await sut.load(accountScope: self.accountScope(sequence: 1)) + + #expect(client.getWatchPageCallCount == 2) + #expect(sut.loadingState == .loaded) + #expect(sut.ask.isAvailable) + + sut.ask.setExpanded(true) + await self.waitUntil(sut.ask.activity == .idle && !sut.ask.suggestions.isEmpty) + #expect(sut.ask.suggestions.map(\.text) == ["Recovered question"]) + } + + @Test("A cancelled outer watch task does not retry") + func cancelledOuterWatchTaskDoesNotRetry() async { + let client = MockYouTubeClient() + let gate = AsyncGate() + client.beforeWatchPageReturn = { + await gate.wait() + } + let video = MockYouTubeClient.makeVideo(videoId: "fixture-video") + let sut = YouTubeWatchViewModel(video: video, client: client) + let loadTask = Task { + await sut.load(accountScope: self.accountScope(sequence: 1)) + } + await self.waitUntil(client.getWatchPageCallCount == 1) + + loadTask.cancel() + await gate.open() + await loadTask.value + + #expect(client.getWatchPageCallCount == 1) + #expect(sut.loadingState == .idle) + #expect(!sut.ask.isAvailable) + } + @Test("Returning to the same watch route reloads discarded Ask state") func cancelThenReloadRestoresAskAvailability() async { let client = MockYouTubeClient() diff --git a/Tests/YouTubeAskCoreTests/YouTubeAskParserTests.swift b/Tests/YouTubeAskCoreTests/YouTubeAskParserTests.swift index f204b9e30..0b1dba6df 100644 --- a/Tests/YouTubeAskCoreTests/YouTubeAskParserTests.swift +++ b/Tests/YouTubeAskCoreTests/YouTubeAskParserTests.swift @@ -274,6 +274,20 @@ struct YouTubeAskParserTests { _ = try YouTubeAskParser.parseConversation(from: mismatchedVisibleText) } + let unsafeLoadingCallback = try Self.conversationEnvelope(chips: [[ + "text": ["simpleText": "Unsafe loading callback"], + "continuation": "fixture-unsafe-loading-continuation", + "onClick": Self.localListMutationCallback( + visibleText: "Unsafe loading callback", + loadingExtraFields: [ + "sendUserQueryCommand": ["placeholder": true], + ] + ), + ]]) + expectYouTubeAskError(.unsupportedChipDecorator) { + _ = try YouTubeAskParser.parseConversation(from: unsafeLoadingCallback) + } + let missingContinuation = try Self.conversationEnvelope(chips: [[ "text": ["simpleText": "Missing command"], ]]) @@ -470,20 +484,32 @@ struct YouTubeAskParserTests { } private static func localListMutationCallback( - visibleText: String + visibleText: String, + loadingExtraFields: [String: Any] = [:] ) -> [String: Any] { - [ + var loadingView: [String: Any] = [ + "animation": Self.loadingAnimation(resource: "fixture-light-animation"), + "darkThemeAnimation": Self.loadingAnimation(resource: "fixture-dark-animation"), + "loadingAnimationA11yLabel": "Loading response", + "targetId": "fixture-loading-target", + ] + loadingView.merge(loadingExtraFields) { _, newValue in newValue } + + return [ "clickTrackingParams": "fixture-tracking-placeholder", "listMutationCommand": [ "operations": [ "operations": [[ "insertItemSectionContent": [ - "contents": [[ - "chatUserTurnViewModel": [ - "backgroundStyle": "CHAT_USER_TURN_BACKGROUND_STYLE_DEFAULT", - "text": visibleText, + "contents": [ + [ + "chatUserTurnViewModel": [ + "backgroundStyle": "CHAT_USER_TURN_BACKGROUND_STYLE_DEFAULT", + "text": visibleText, + ], ], - ]], + ["chatLoadingViewModel": loadingView], + ], "insertByPositionInSection": [ "position": "ITEM_SECTION_POSITION_END", "sectionTargetId": "fixture-section-target", @@ -504,6 +530,17 @@ struct YouTubeAskParserTests { ] } + private static func loadingAnimation(resource: String) -> [String: Any] { + [ + "lottieAnimationViewModel": [ + "loop": true, + "trustedAnimationUrl": [ + "privateDoNotAccessOrElseTrustedResourceUrlWrappedValue": resource, + ], + ], + ] + } + private static func bootstrapObject(tokens: [String]) -> [String: Any] { [ "engagementPanels": [[ diff --git a/docs/adr/0032-youtube-ask-gemini.md b/docs/adr/0032-youtube-ask-gemini.md index 2ad9014df..d3a0f0c35 100644 --- a/docs/adr/0032-youtube-ask-gemini.md +++ b/docs/adr/0032-youtube-ask-gemini.md @@ -59,17 +59,21 @@ Wire-level observations and the API Explorer workflow remain documented in the displayed verbatim and are not localized by Kaset. 5. **Fail closed on unsupported or ambiguous data.** Strict parsing recognizes only confirmed YouChat structures, bounded wire formats, and supported - message/chip containers. A chip may carry the exact observed `onClick.listMutationCommand` UI - mutation, which is ignored rather than preserved or executed only when its - inserted user-turn text matches the chip label and every key matches the - allowlisted local insertion/scroll shape. Any other callback rejects the chip - set. Multiple panel-bootstrap commands reject the + message/chip containers. A chip may carry the exact observed + `onClick.listMutationCommand` UI mutation, which is ignored rather than + preserved or executed only when its inserted user-turn text matches the chip + label and every key matches the allowlisted local user-turn/loading-animation + insertion and scroll shape. Any other callback rejects the chip set. Multiple + panel-bootstrap commands reject the bootstrap unless validated direct chips make panel materialization unnecessary, in which case Kaset discards every ambiguous panel command. Other ambiguity, malformed or oversized responses, unsupported decorators, identity changes, or uncertain submission outcomes disable the current session. There is no - automatic retry; the UI may offer New Chat, which starts from a fresh watch - bootstrap and replaces the old conversation only after preparation succeeds. + automatic retry for panel materialization or suggestion submission; the + read-only watch bootstrap may retry once only after an internal identity-fence + cancellation while the same route load remains current. The UI may offer New + Chat, which starts from a fresh watch bootstrap and replaces the old + conversation only after preparation succeeds. 6. **Select the production request profile explicitly.** The production app selects the fixed WEB profile. The July 28, 2026 parity run was inconclusive because the exported session appeared signed out; activation was subsequently diff --git a/docs/api-discovery.md b/docs/api-discovery.md index 4ca6639bb..4004bcf12 100644 --- a/docs/api-discovery.md +++ b/docs/api-discovery.md @@ -1489,8 +1489,9 @@ request. `chipData` entries could include a top-level `onClick` callback containing one local interaction command. - The root `chipData.continuation` remained the only replayed capability. Kaset - ignores the callback only when it matches the observed `listMutationCommand` structure, inserts the same - visible label as the chip, and contains no extra keys or request capability. + ignores the callback only when it matches the observed `listMutationCommand` + structure, inserts the same visible label plus the allowlisted loading-animation + placeholder, and contains no extra keys or request capability. - The same response exposed multiple distinct panel-bootstrap continuations. When direct chips are already present, Kaset discards the ambiguous panel command instead of guessing; ambiguity still rejects bootstraps that have no @@ -1601,7 +1602,7 @@ The `--brand` flag sets `context.user.onBehalfOfUser` in the request body. See [ | Date | Changes | |------|---------| -| 2026-08-01 | Revalidated an eligible signed-in production watch response; added strict support for inert chip `onClick` callbacks and preserved direct chips while discarding ambiguous panel-only commands | +| 2026-08-01 | Revalidated an eligible signed-in production watch response; added strict support for the observed local user-turn/loading `onClick` mutation, preserved direct chips while discarding ambiguous panel-only commands, and added one bounded read-only retry for internal identity-fence cancellation | | 2026-07-30 | Enabled the fixed WEB Ask request profile in the production app by explicit product direction; eligibility and all strict parser, identity, and transport gates remain enforced | | 2026-07-28 | Added redacted read-only `ask-video-parity` tooling backed by `YouTubeAskCore`; all three profiles returned HTTP 200 `next` responses but the exported session was treated as signed out, so no profile passed and production remains disabled | | 2026-07-27 | Live-validated YouTube Ask Gemini / YouChat summary, follow-up, and two fresh chats; added guarded `ask-video-live-test`, corrected direct chips to `get_panel`, retained read-only `ask-video-audit`, and documented redaction/auth constraints | diff --git a/docs/testing.md b/docs/testing.md index a5b21be8e..3bf335ef5 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -288,17 +288,19 @@ YouTube Ask has two test layers: 1. `YouTubeAskCoreTests` proves the Foundation-only boundary: bounded JSON, XSSI, NDJSON, and length-prefixed decoding; strict YouChat ancestry and chip - extraction; decoy and unsupported-decorator rejection; exact `onClick.listMutationCommand` - callback acceptance without execution and unknown-command rejection; direct-chip preservation when panel + extraction; decoy and unsupported-decorator rejection; exact + `onClick.listMutationCommand` user-turn/loading callback acceptance without + execution and unknown-command rejection; direct-chip preservation when panel commands are ambiguous; server-order preservation; visible-text sanitization; exact direct-chip request bodies; and redacted opaque-command behavior. 2. `KasetTests` covers `YouTubeClient` and view-model integration: one shared watch `next` request, signed-in primary-account gating, exact `get_panel` URL/body, forbidden-field absence, monotonic message IDs, no cache or retry, - same-origin redirects, HTTP/error mapping, identity-generation fences, lazy - preparation, single-flight submission, transactional New Chat, cancellation, - and prevention of command reuse. + same-origin redirects, HTTP/error mapping, identity-generation fences, a + single read-only watch retry after internal identity cancellation, no retry + after outer task cancellation, lazy preparation, single-flight submission, + transactional New Chat, cancellation, and prevention of command reuse. Fixtures must be small, hand-authored, and visibly synthetic. Use placeholder values such as `fixture-video-a` and `fixture-continuation-a`; never copy cookies, diff --git a/docs/youtube.md b/docs/youtube.md index dbc5c50e4..b0d59a110 100644 --- a/docs/youtube.md +++ b/docs/youtube.md @@ -64,7 +64,9 @@ requests. `getWatchPage(videoId:)` parses normal watch data and an optional Ask bootstrap from the same `next` response. High-level conversation operations use an Ask-specific bounded, same-origin, no-cache, no-automatic-retry transport and expose only sanitized text plus local IDs to UI code. Opaque server commands stay -inside memory-bound domain values. See +inside memory-bound domain values. A read-only watch bootstrap may retry once +when an account-scope publication resets the client after parsing; `get_panel` +and suggestion submissions are never retried automatically. See [ADR-0032](adr/0032-youtube-ask-gemini.md). ### Endpoints From 01165d2ce408c02a7bd50d12bc4808910c936b06 Mon Sep 17 00:00:00 2001 From: Sertac Ozercan Date: Fri, 31 Jul 2026 22:12:36 -0700 Subject: [PATCH 07/18] fix(youtube): parse and render Ask replies Signed-off-by: Sertac Ozercan --- .../YouTube/YouTubeAskMarkdownView.swift | 298 ++++++++++++++++++ .../Views/YouTube/YouTubeAskPanelView.swift | 7 +- .../YouTubeAskParser+ListMutation.swift | 64 ++++ Sources/YouTubeAskCore/YouTubeAskParser.swift | 31 +- .../YouTubeAskClientTestFixtures.swift | 59 ++++ Tests/KasetTests/YouTubeAskClientTests.swift | 2 +- .../KasetTests/YouTubeAskMarkdownTests.swift | 85 +++++ .../YouTubeAskParserTests.swift | 121 +++++++ docs/adr/0032-youtube-ask-gemini.md | 5 +- docs/api-discovery.md | 18 ++ docs/testing.md | 6 +- docs/youtube.md | 9 +- 12 files changed, 692 insertions(+), 13 deletions(-) create mode 100644 Sources/Kaset/Views/YouTube/YouTubeAskMarkdownView.swift create mode 100644 Sources/YouTubeAskCore/YouTubeAskParser+ListMutation.swift create mode 100644 Tests/KasetTests/YouTubeAskClientTestFixtures.swift create mode 100644 Tests/KasetTests/YouTubeAskMarkdownTests.swift diff --git a/Sources/Kaset/Views/YouTube/YouTubeAskMarkdownView.swift b/Sources/Kaset/Views/YouTube/YouTubeAskMarkdownView.swift new file mode 100644 index 000000000..9f536632c --- /dev/null +++ b/Sources/Kaset/Views/YouTube/YouTubeAskMarkdownView.swift @@ -0,0 +1,298 @@ +import Foundation +import SwiftUI + +// MARK: - YouTubeAskMarkdown + +enum YouTubeAskMarkdown { + struct OrderedListItem: Equatable { + let number: Int + let text: String + } + + enum Block: Equatable { + case heading(level: Int, text: String) + case paragraph(String) + case unorderedList([String]) + case orderedList([OrderedListItem]) + case blockQuote(String) + case codeBlock(String) + case thematicBreak + } + + static func blocks(from markdown: String) -> [Block] { + let lines = self.normalizedLines(from: markdown) + var blocks: [Block] = [] + var paragraphLines: [String] = [] + var index = 0 + + func flushParagraph() { + guard !paragraphLines.isEmpty else { return } + blocks.append(.paragraph(paragraphLines.joined(separator: " "))) + paragraphLines.removeAll(keepingCapacity: true) + } + + while index < lines.count { + let trimmed = lines[index].trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + flushParagraph() + index += 1 + continue + } + + if trimmed.hasPrefix("```") { + flushParagraph() + index += 1 + var codeLines: [String] = [] + while index < lines.count, + !lines[index].trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("```") + { + codeLines.append(lines[index]) + index += 1 + } + if index < lines.count { + index += 1 + } + blocks.append(.codeBlock(codeLines.joined(separator: "\n"))) + continue + } + + if let heading = self.heading(from: trimmed) { + flushParagraph() + blocks.append(.heading(level: heading.level, text: heading.text)) + index += 1 + continue + } + + if self.isThematicBreak(trimmed) { + flushParagraph() + blocks.append(.thematicBreak) + index += 1 + continue + } + + if let item = self.unorderedListItem(from: trimmed) { + flushParagraph() + var items = [item] + index += 1 + while index < lines.count, + let next = self.unorderedListItem( + from: lines[index].trimmingCharacters(in: .whitespacesAndNewlines) + ) + { + items.append(next) + index += 1 + } + blocks.append(.unorderedList(items)) + continue + } + + if let item = self.orderedListItem(from: trimmed) { + flushParagraph() + var items = [item] + index += 1 + while index < lines.count, + let next = self.orderedListItem( + from: lines[index].trimmingCharacters(in: .whitespacesAndNewlines) + ) + { + items.append(next) + index += 1 + } + blocks.append(.orderedList(items)) + continue + } + + if trimmed.hasPrefix("> ") { + flushParagraph() + var quotedLines = [String(trimmed.dropFirst(2))] + index += 1 + while index < lines.count { + let next = lines[index].trimmingCharacters(in: .whitespacesAndNewlines) + guard next.hasPrefix("> ") else { break } + quotedLines.append(String(next.dropFirst(2))) + index += 1 + } + blocks.append(.blockQuote(quotedLines.joined(separator: " "))) + continue + } + + paragraphLines.append(trimmed) + index += 1 + } + + flushParagraph() + return blocks + } + + static func inlineAttributedString(_ markdown: String) -> AttributedString { + var attributed = (try? AttributedString( + markdown: markdown, + options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) + )) ?? AttributedString(markdown) + + attributed.link = nil + return attributed + } + + static func plainText(from markdown: String) -> String { + self.blocks(from: markdown).map { block in + switch block { + case let .heading(_, text), let .paragraph(text), let .blockQuote(text): + String(self.inlineAttributedString(text).characters) + case let .unorderedList(items): + items.map { String(self.inlineAttributedString($0).characters) } + .joined(separator: ". ") + case let .orderedList(items): + items.map { item in + "\(item.number). \(String(self.inlineAttributedString(item.text).characters))" + } + .joined(separator: "\n") + case let .codeBlock(code): + code + case .thematicBreak: + "" + } + } + .filter { !$0.isEmpty } + .joined(separator: "\n") + } + + private static func normalizedLines(from markdown: String) -> [String] { + markdown + .replacingOccurrences(of: "\r\n", with: "\n") + .replacingOccurrences(of: "\r", with: "\n") + .components(separatedBy: "\n") + } + + private static func heading(from line: String) -> (level: Int, text: String)? { + let prefix = line.prefix { $0 == "#" } + guard (1 ... 6).contains(prefix.count), + line.dropFirst(prefix.count).first == " " + else { + return nil + } + let text = String(line.dropFirst(prefix.count + 1)) + return text.isEmpty ? nil : (prefix.count, text) + } + + private static func unorderedListItem(from line: String) -> String? { + guard line.hasPrefix("- ") || line.hasPrefix("* ") || line.hasPrefix("+ ") else { + return nil + } + let text = String(line.dropFirst(2)) + return text.isEmpty ? nil : text + } + + private static func orderedListItem(from line: String) -> OrderedListItem? { + guard let marker = line.range(of: #"^\d+\.\s+"#, options: .regularExpression), + let number = Int(line[.. Bool { + let stripped = line.replacingOccurrences(of: " ", with: "") + guard stripped.count >= 3, let first = stripped.first, + first == "-" || first == "*" || first == "_" + else { + return false + } + return stripped.allSatisfy { $0 == first } + } +} + +// MARK: - YouTubeAskMarkdownView + +struct YouTubeAskMarkdownView: View { + let markdown: String + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + ForEach(Array(YouTubeAskMarkdown.blocks(from: self.markdown).enumerated()), id: \.offset) { _, block in + self.blockView(block) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + } + + @ViewBuilder + private func blockView(_ block: YouTubeAskMarkdown.Block) -> some View { + switch block { + case let .heading(level, text): + Text(YouTubeAskMarkdown.inlineAttributedString(text)) + .font(self.headingFont(level: level)) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + + case let .paragraph(text): + self.markdownText(text) + + case let .unorderedList(items): + VStack(alignment: .leading, spacing: 7) { + ForEach(Array(items.enumerated()), id: \.offset) { _, item in + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(verbatim: "•") + .foregroundStyle(.secondary) + self.markdownText(item) + } + } + } + + case let .orderedList(items): + VStack(alignment: .leading, spacing: 7) { + ForEach(Array(items.enumerated()), id: \.offset) { _, item in + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(verbatim: "\(item.number).") + .foregroundStyle(.secondary) + .monospacedDigit() + self.markdownText(item.text) + } + } + } + + case let .blockQuote(text): + HStack(alignment: .top, spacing: 9) { + Rectangle() + .fill(.secondary.opacity(0.45)) + .frame(width: 3) + self.markdownText(text) + .foregroundStyle(.secondary) + } + + case let .codeBlock(code): + ScrollView(.horizontal) { + Text(verbatim: code) + .font(.system(.caption, design: .monospaced)) + .fixedSize(horizontal: true, vertical: false) + } + .padding(10) + .background(.quaternary.opacity(0.5), in: .rect(cornerRadius: 8)) + + case .thematicBreak: + Divider() + } + } + + private func markdownText(_ text: String) -> some View { + Text(YouTubeAskMarkdown.inlineAttributedString(text)) + .font(.callout) + .lineSpacing(2) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func headingFont(level: Int) -> Font { + switch level { + case 1: + .title3.weight(.bold) + case 2: + .headline.weight(.bold) + default: + .callout.weight(.semibold) + } + } +} diff --git a/Sources/Kaset/Views/YouTube/YouTubeAskPanelView.swift b/Sources/Kaset/Views/YouTube/YouTubeAskPanelView.swift index e3c33831f..ef6822f67 100644 --- a/Sources/Kaset/Views/YouTube/YouTubeAskPanelView.swift +++ b/Sources/Kaset/Views/YouTube/YouTubeAskPanelView.swift @@ -144,16 +144,15 @@ struct YouTubeAskPanelView: View { ) ) case .assistant: - Text(verbatim: message.text) - .font(.callout) + YouTubeAskMarkdownView(markdown: message.text) .foregroundStyle(.primary) .multilineTextAlignment(.leading) .fixedSize(horizontal: false, vertical: true) - .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .ignore) .accessibilityLabel( String( - localized: "YouTube response: \(message.text)", + localized: "YouTube response: \(YouTubeAskMarkdown.plainText(from: message.text))", comment: "VoiceOver label for an assistant turn in the YouTube Ask Gemini transcript" ) ) diff --git a/Sources/YouTubeAskCore/YouTubeAskParser+ListMutation.swift b/Sources/YouTubeAskCore/YouTubeAskParser+ListMutation.swift new file mode 100644 index 000000000..6acf68b3f --- /dev/null +++ b/Sources/YouTubeAskCore/YouTubeAskParser+ListMutation.swift @@ -0,0 +1,64 @@ +import Foundation + +extension YouTubeAskParser { + /// Current `get_panel` responses insert assistant items and follow-up chips + /// through one top-level list mutation. Only contents under the confirmed + /// insertion path are eligible; sibling framework/entity updates remain + /// ignored. + static func parseConfirmedMutationCommand( + _ value: YouTubeAskJSONValue, + content: inout ConversationAccumulator + ) throws { + guard let command = value.objectValue else { + throw YouTubeAskCoreError.malformedWireResponse + } + guard let listMutationValue = command["listMutationCommand"] else { return } + guard let listMutation = listMutationValue.objectValue, + let operationsContainer = listMutation["operations"]?.objectValue, + let operations = operationsContainer["operations"]?.arrayValue + else { + throw YouTubeAskCoreError.malformedWireResponse + } + + for operation in operations { + guard let operationObject = operation.objectValue else { + throw YouTubeAskCoreError.malformedWireResponse + } + guard let insertion = operationObject["insertItemSectionContent"] else { + continue + } + try Self.parseConfirmedInsertItemSectionContent( + insertion, + content: &content + ) + } + } + + private static func parseConfirmedInsertItemSectionContent( + _ value: YouTubeAskJSONValue, + content: inout ConversationAccumulator + ) throws { + guard let insertion = value.objectValue, + Set(insertion.keys) == ["contents", "insertByPositionInSection"], + let contents = insertion["contents"]?.arrayValue, + let placement = insertion["insertByPositionInSection"]?.objectValue, + Set(placement.keys) == ["position", "sectionTargetId"], + placement["position"]?.stringValue == "ITEM_SECTION_POSITION_END", + hasNonemptyString(placement["sectionTargetId"]) + else { + throw YouTubeAskCoreError.malformedWireResponse + } + + for item in contents { + try Self.parseConfirmedContinuationItem( + item, + content: &content + ) + } + } + + private static func hasNonemptyString(_ value: YouTubeAskJSONValue?) -> Bool { + guard let string = value?.stringValue else { return false } + return !string.isEmpty + } +} diff --git a/Sources/YouTubeAskCore/YouTubeAskParser.swift b/Sources/YouTubeAskCore/YouTubeAskParser.swift index 580262b1c..86baa96a2 100644 --- a/Sources/YouTubeAskCore/YouTubeAskParser.swift +++ b/Sources/YouTubeAskCore/YouTubeAskParser.swift @@ -82,7 +82,7 @@ package enum YouTubeAskParser { ) } - private struct ConversationAccumulator { + package struct ConversationAccumulator { var messages: [YouTubeAskParsedMessage] = [] var suggestions: [YouTubeAskParsedSuggestion] = [] } @@ -283,8 +283,31 @@ package enum YouTubeAskParser { _ response: [String: YouTubeAskJSONValue], content: inout ConversationAccumulator ) throws { - guard let commandsValue = response["onResponseReceivedCommands"] else { return } - guard let commands = commandsValue.arrayValue else { + let legacyCommands = response["onResponseReceivedCommands"] + let mutationCommand = response["onResponseReceivedCommand"] + guard legacyCommands == nil || mutationCommand == nil else { + throw YouTubeAskCoreError.malformedWireResponse + } + + if let legacyCommands { + try Self.parseConfirmedLegacyCommands( + legacyCommands, + content: &content + ) + } + if let mutationCommand { + try Self.parseConfirmedMutationCommand( + mutationCommand, + content: &content + ) + } + } + + private static func parseConfirmedLegacyCommands( + _ value: YouTubeAskJSONValue, + content: inout ConversationAccumulator + ) throws { + guard let commands = value.arrayValue else { throw YouTubeAskCoreError.malformedWireResponse } @@ -320,7 +343,7 @@ package enum YouTubeAskParser { } } - private static func parseConfirmedContinuationItem( + package static func parseConfirmedContinuationItem( _ value: YouTubeAskJSONValue, content: inout ConversationAccumulator ) throws { diff --git a/Tests/KasetTests/YouTubeAskClientTestFixtures.swift b/Tests/KasetTests/YouTubeAskClientTestFixtures.swift new file mode 100644 index 000000000..f6530fe12 --- /dev/null +++ b/Tests/KasetTests/YouTubeAskClientTestFixtures.swift @@ -0,0 +1,59 @@ +import Foundation + +extension YouTubeAskClientTests { + static let mutationConversationData = Data( + #""" + { + "onResponseReceivedCommand": { + "listMutationCommand": { + "operations": { + "operations": [ + { + "insertItemSectionContent": { + "contents": [ + { + "youChatItemViewModel": { + "chatResponseStyle": "CHAT_RESPONSE_STYLE_DEFAULT", + "text": { + "content": "A synthetic assistant response.", + "styleRuns": [ + { + "startIndex": 0, + "length": 9, + "weightLabel": "FONT_WEIGHT_MEDIUM" + } + ] + }, + "transparentBackground": true + } + }, + { + "youChatItemViewModel": { + "chipsData": { + "chipData": [ + { + "text": {"content": "Continue"}, + "continuation": "fixture-next-chip" + } + ] + } + } + } + ], + "insertByPositionInSection": { + "position": "ITEM_SECTION_POSITION_END", + "sectionTargetId": "fixture-response-section" + } + } + } + ] + } + } + }, + "frameworkUpdates": { + "entityBatchUpdate": {"mutations": []} + } + } + """#.utf8 + ) +} diff --git a/Tests/KasetTests/YouTubeAskClientTests.swift b/Tests/KasetTests/YouTubeAskClientTests.swift index 55e61090c..378a05f6f 100644 --- a/Tests/KasetTests/YouTubeAskClientTests.swift +++ b/Tests/KasetTests/YouTubeAskClientTests.swift @@ -125,7 +125,7 @@ struct YouTubeAskClientTests { let formData = try #require(body["formData"] as? [String: Any]) let composer = try #require(formData["inputComposerFormData"] as? [String: Any]) try messageIDs.append(#require(composer["clientMessageId"] as? String)) - return Self.response(for: request, data: Self.conversationData) + return Self.response(for: request, data: Self.mutationConversationData) default: Issue.record("Direct chip request retried unexpectedly") return Self.response(for: request, data: Data(#"{}"#.utf8)) diff --git a/Tests/KasetTests/YouTubeAskMarkdownTests.swift b/Tests/KasetTests/YouTubeAskMarkdownTests.swift new file mode 100644 index 000000000..e61305843 --- /dev/null +++ b/Tests/KasetTests/YouTubeAskMarkdownTests.swift @@ -0,0 +1,85 @@ +import Foundation +import Testing +@testable import Kaset + +@Suite("YouTube Ask Markdown") +struct YouTubeAskMarkdownTests { + @Test("Parses paragraphs, headings, lists, quotes, and code blocks") + func parsesBlocks() { + let markdown = """ + Intro paragraph. + + **Main Headlines:** + * **First:** Details + * Second + + 1. One + 2. Two + + > Check this carefully. + + ```swift + let value = 1 + ``` + """ + + #expect(YouTubeAskMarkdown.blocks(from: markdown) == [ + .paragraph("Intro paragraph."), + .paragraph("**Main Headlines:**"), + .unorderedList(["**First:** Details", "Second"]), + .orderedList([ + .init(number: 1, text: "One"), + .init(number: 2, text: "Two"), + ]), + .blockQuote("Check this carefully."), + .codeBlock("let value = 1"), + ]) + } + + @Test("Preserves explicit ordered-list markers visually and for accessibility") + func preservesOrderedListMarkers() { + let markdown = """ + 5. Fifth + 7. Seventh + """ + + #expect(YouTubeAskMarkdown.blocks(from: markdown) == [ + .orderedList([ + .init(number: 5, text: "Fifth"), + .init(number: 7, text: "Seventh"), + ]), + ]) + #expect(YouTubeAskMarkdown.plainText(from: markdown) == "5. Fifth\n7. Seventh") + } + + @Test("Renders inline emphasis while removing link attributes") + func safeInlineMarkdown() { + let attributed = YouTubeAskMarkdown.inlineAttributedString( + "**Bold**, *italic*, `code`, [first](/relative/one), and [second](/relative/two)" + ) + + #expect(String(attributed.characters) == "Bold, italic, code, first, and second") + #expect(attributed.runs.allSatisfy { $0.link == nil }) + #expect(attributed.runs.contains { run in + run.inlinePresentationIntent?.contains(.stronglyEmphasized) == true + }) + #expect(attributed.runs.contains { run in + run.inlinePresentationIntent?.contains(.emphasized) == true + }) + #expect(attributed.runs.contains { run in + run.inlinePresentationIntent?.contains(.code) == true + }) + } + + @Test("Accessibility text omits Markdown syntax") + func plainAccessibilityText() { + let markdown = """ + **Summary** + + * **First:** Details + * Second + """ + + #expect(YouTubeAskMarkdown.plainText(from: markdown) == "Summary\nFirst: Details. Second") + } +} diff --git a/Tests/YouTubeAskCoreTests/YouTubeAskParserTests.swift b/Tests/YouTubeAskCoreTests/YouTubeAskParserTests.swift index 0b1dba6df..a7bcf7e81 100644 --- a/Tests/YouTubeAskCoreTests/YouTubeAskParserTests.swift +++ b/Tests/YouTubeAskCoreTests/YouTubeAskParserTests.swift @@ -361,6 +361,70 @@ struct YouTubeAskParserTests { #expect(conversation.messages.map(\.text) == ["Confirmed message"]) } + @Test("Parses the confirmed list-mutation response container") + func parsesConfirmedListMutationResponse() throws { + let envelope = try Self.envelope([ + "onResponseReceivedCommand": [ + "listMutationCommand": [ + "operations": [ + "operations": [[ + "insertItemSectionContent": [ + "contents": [ + [ + "youChatItemViewModel": [ + "chatResponseStyle": "CHAT_RESPONSE_STYLE_DEFAULT", + "text": [ + "content": "Synthetic summary response", + "styleRuns": [[ + "startIndex": 0, + "length": 9, + "weightLabel": "FONT_WEIGHT_MEDIUM", + ]], + ], + "transparentBackground": true, + ], + ], + [ + "youChatItemViewModel": [ + "chatResponseStyle": "CHAT_RESPONSE_STYLE_DEFAULT", + "videoResultsData": ["placeholder": true], + "transparentBackground": true, + ], + ], + [ + "youChatItemViewModel": [ + "chipsData": [ + "chipData": [[ + "text": ["content": "Continue"], + "continuation": "fixture-mutation-follow-up", + "onClick": Self.localListMutationCallback( + visibleText: "Continue" + ), + ]], + ], + ], + ], + ], + "insertByPositionInSection": [ + "position": "ITEM_SECTION_POSITION_END", + "sectionTargetId": "fixture-response-section", + ], + ], + ]], + ], + ], + ], + "frameworkUpdates": [ + "entityBatchUpdate": ["mutations": []], + ], + ]) + + let conversation = try YouTubeAskParser.parseConversation(from: envelope) + #expect(conversation.messages.map(\.text) == ["Synthetic summary response"]) + #expect(conversation.suggestions.map(\.label) == ["Continue"]) + #expect(conversation.suggestions.first?.command.continuation == "fixture-mutation-follow-up") + } + @Test("Rejects YouChat-shaped content in unsupported response containers") func rejectsUnsupportedResponseContainers() throws { let message = [ @@ -414,6 +478,63 @@ struct YouTubeAskParserTests { _ = try YouTubeAskParser.parseConversation(from: missingItems) } + let mixedContainers = try Self.envelope([ + "onResponseReceivedCommands": [], + "onResponseReceivedCommand": [ + "listMutationCommand": [ + "operations": ["operations": []], + ], + ], + ]) + expectYouTubeAskError(.malformedWireResponse) { + _ = try YouTubeAskParser.parseConversation(from: mixedContainers) + } + + let malformedMutation = try Self.envelope([ + "onResponseReceivedCommand": [ + "listMutationCommand": ["operations": ["unexpected": true]], + ], + ]) + expectYouTubeAskError(.malformedWireResponse) { + _ = try YouTubeAskParser.parseConversation(from: malformedMutation) + } + + let missingMutationPlacement = try Self.envelope([ + "onResponseReceivedCommand": [ + "listMutationCommand": [ + "operations": [ + "operations": [[ + "insertItemSectionContent": ["contents": []], + ]], + ], + ], + ], + ]) + expectYouTubeAskError(.malformedWireResponse) { + _ = try YouTubeAskParser.parseConversation(from: missingMutationPlacement) + } + + let wrongMutationPosition = try Self.envelope([ + "onResponseReceivedCommand": [ + "listMutationCommand": [ + "operations": [ + "operations": [[ + "insertItemSectionContent": [ + "contents": [], + "insertByPositionInSection": [ + "position": "ITEM_SECTION_POSITION_START", + "sectionTargetId": "fixture-response-section", + ], + ], + ]], + ], + ], + ], + ]) + expectYouTubeAskError(.malformedWireResponse) { + _ = try YouTubeAskParser.parseConversation(from: wrongMutationPosition) + } + let ambiguousItem = try Self.conversationEnvelope(items: [[ "youChatTextMessageViewModel": [ "text": ["content": "Ambiguous message"], diff --git a/docs/adr/0032-youtube-ask-gemini.md b/docs/adr/0032-youtube-ask-gemini.md index d3a0f0c35..fe7333fca 100644 --- a/docs/adr/0032-youtube-ask-gemini.md +++ b/docs/adr/0032-youtube-ask-gemini.md @@ -59,7 +59,10 @@ Wire-level observations and the API Explorer workflow remain documented in the displayed verbatim and are not localized by Kaset. 5. **Fail closed on unsupported or ambiguous data.** Strict parsing recognizes only confirmed YouChat structures, bounded wire formats, and supported - message/chip containers. A chip may carry the exact observed + message/chip containers, including the confirmed singular + `onResponseReceivedCommand.listMutationCommand` insertion path. Result/link + view models and sibling framework updates remain outside the visible model. + A chip may carry the exact observed `onClick.listMutationCommand` UI mutation, which is ignored rather than preserved or executed only when its inserted user-turn text matches the chip label and every key matches the allowlisted local user-turn/loading-animation diff --git a/docs/api-discovery.md b/docs/api-discovery.md index 4004bcf12..721136bee 100644 --- a/docs/api-discovery.md +++ b/docs/api-discovery.md @@ -1483,6 +1483,24 @@ inventing them. The chip's `id`, visible text, click-tracking command, and the free-text composer's `sendUserQueryCommand` are not copied into this direct-chip request. +**Current direct-chip response container (August 1, 2026):** + +Successful `get_panel` responses used a top-level singular +`onResponseReceivedCommand.listMutationCommand`. Assistant text items and +follow-up chips were inserted under: + +```text +operations.operations[].insertItemSectionContent.contents[].youChatItemViewModel +``` + +Text-bearing `youChatItemViewModel` objects expose `text.content` plus optional +style/action metadata. Chip-bearing objects expose `chipsData`. Kaset parses only +those two visible surfaces under the confirmed insertion path; result/link +objects such as `videoResultsData` and `webData`, plus sibling `frameworkUpdates`, +remain ignored. The older +`onResponseReceivedCommands[].appendContinuationItemsAction.continuationItems[]` +shape remains supported for previously validated responses. + **Signed-in production compatibility check on August 1, 2026**: - The authenticated watch response exposed validated direct chips whose diff --git a/docs/testing.md b/docs/testing.md index 3bf335ef5..6fbc57c95 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -291,8 +291,10 @@ YouTube Ask has two test layers: extraction; decoy and unsupported-decorator rejection; exact `onClick.listMutationCommand` user-turn/loading callback acceptance without execution and unknown-command rejection; direct-chip preservation when panel - commands are ambiguous; server-order preservation; visible-text sanitization; - exact direct-chip request bodies; + commands are ambiguous; legacy append-action and current singular + list-mutation response parsing; server-order preservation; visible-text + sanitization; native Markdown block parsing with non-interactive links; exact + direct-chip request bodies; and redacted opaque-command behavior. 2. `KasetTests` covers `YouTubeClient` and view-model integration: one shared watch `next` request, signed-in primary-account gating, exact `get_panel` diff --git a/docs/youtube.md b/docs/youtube.md index b0d59a110..b50de5862 100644 --- a/docs/youtube.md +++ b/docs/youtube.md @@ -108,11 +108,18 @@ currently serves. ### Ask Gemini Activation Gate +Current `get_panel` replies arrive through a singular list-mutation command. +Kaset accepts text and follow-up chips only from the confirmed inserted +`youChatItemViewModel` contents, while generated result/link objects remain +non-interactive and undisplayed. + The Ask implementation is intentionally chips-only: expanding the collapsed card may prepare an initial panel, but it never generates an answer until the user selects a server-issued suggestion. Follow-up chips are also server-issued; there is no free-text composer or `streaming_panel` path. Visible labels and -answers are sanitized but not localized by Kaset. +answers are sanitized but not localized by Kaset. Assistant messages render +native Markdown blocks and inline emphasis; link destinations are stripped and +never become interactive. Production activation uses the fixed WEB request profile selected on July 30, 2026. Eligibility remains account- and video-scoped: signed-out, guest, brand, From faa38c8a161b9bc2213eceab6e5dfdec4a8ed777 Mon Sep 17 00:00:00 2001 From: Sertac Ozercan Date: Fri, 31 Jul 2026 22:46:46 -0700 Subject: [PATCH 08/18] fix(youtube): accept live Ask insertion position Signed-off-by: Sertac Ozercan --- Sources/YouTubeAskCore/YouTubeAskParser+ListMutation.swift | 2 +- Tests/KasetTests/YouTubeAskClientTestFixtures.swift | 2 +- Tests/YouTubeAskCoreTests/YouTubeAskParserTests.swift | 2 +- docs/api-discovery.md | 4 ++++ 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/Sources/YouTubeAskCore/YouTubeAskParser+ListMutation.swift b/Sources/YouTubeAskCore/YouTubeAskParser+ListMutation.swift index 6acf68b3f..fa213e9c3 100644 --- a/Sources/YouTubeAskCore/YouTubeAskParser+ListMutation.swift +++ b/Sources/YouTubeAskCore/YouTubeAskParser+ListMutation.swift @@ -43,7 +43,7 @@ extension YouTubeAskParser { let contents = insertion["contents"]?.arrayValue, let placement = insertion["insertByPositionInSection"]?.objectValue, Set(placement.keys) == ["position", "sectionTargetId"], - placement["position"]?.stringValue == "ITEM_SECTION_POSITION_END", + placement["position"]?.stringValue == "INSERTION_POSITION_LAST", hasNonemptyString(placement["sectionTargetId"]) else { throw YouTubeAskCoreError.malformedWireResponse diff --git a/Tests/KasetTests/YouTubeAskClientTestFixtures.swift b/Tests/KasetTests/YouTubeAskClientTestFixtures.swift index f6530fe12..ba111be4b 100644 --- a/Tests/KasetTests/YouTubeAskClientTestFixtures.swift +++ b/Tests/KasetTests/YouTubeAskClientTestFixtures.swift @@ -41,7 +41,7 @@ extension YouTubeAskClientTests { } ], "insertByPositionInSection": { - "position": "ITEM_SECTION_POSITION_END", + "position": "INSERTION_POSITION_LAST", "sectionTargetId": "fixture-response-section" } } diff --git a/Tests/YouTubeAskCoreTests/YouTubeAskParserTests.swift b/Tests/YouTubeAskCoreTests/YouTubeAskParserTests.swift index a7bcf7e81..30fa427a3 100644 --- a/Tests/YouTubeAskCoreTests/YouTubeAskParserTests.swift +++ b/Tests/YouTubeAskCoreTests/YouTubeAskParserTests.swift @@ -406,7 +406,7 @@ struct YouTubeAskParserTests { ], ], "insertByPositionInSection": [ - "position": "ITEM_SECTION_POSITION_END", + "position": "INSERTION_POSITION_LAST", "sectionTargetId": "fixture-response-section", ], ], diff --git a/docs/api-discovery.md b/docs/api-discovery.md index 721136bee..d09fe41e3 100644 --- a/docs/api-discovery.md +++ b/docs/api-discovery.md @@ -1493,6 +1493,10 @@ follow-up chips were inserted under: operations.operations[].insertItemSectionContent.contents[].youChatItemViewModel ``` +The confirmed insertion metadata uses `position: INSERTION_POSITION_LAST` with a +nonempty section target. Other positions or missing placement metadata fail +closed. + Text-bearing `youChatItemViewModel` objects expose `text.content` plus optional style/action metadata. Chip-bearing objects expose `chipsData`. Kaset parses only those two visible surfaces under the confirmed insertion path; result/link From 09d4ec6b268d8236095211483f8c2ecc5dae38c2 Mon Sep 17 00:00:00 2001 From: Sertac Ozercan Date: Sat, 1 Aug 2026 02:32:17 -0700 Subject: [PATCH 09/18] feat(youtube): move Ask Gemini to toolbar overlay Signed-off-by: Sertac Ozercan --- .../YouTube/YouTubeAskViewModel.swift | 6 +- .../Views/YouTube/YouTubeAskPanelView.swift | 267 +++++++++++++++--- .../Views/YouTube/YouTubeWatchView.swift | 26 +- .../KasetTests/YouTubeAskViewModelTests.swift | 68 ++++- docs/adr/0032-youtube-ask-gemini.md | 9 +- docs/testing.md | 2 + docs/youtube.md | 18 +- 7 files changed, 330 insertions(+), 66 deletions(-) diff --git a/Sources/Kaset/ViewModels/YouTube/YouTubeAskViewModel.swift b/Sources/Kaset/ViewModels/YouTube/YouTubeAskViewModel.swift index 5124562b6..9797c3596 100644 --- a/Sources/Kaset/ViewModels/YouTube/YouTubeAskViewModel.swift +++ b/Sources/Kaset/ViewModels/YouTube/YouTubeAskViewModel.swift @@ -61,8 +61,9 @@ final class YouTubeAskViewModel { self.isAvailable && (self.hasStarted || self.requiresNewChat) } - /// Replaces all prior state with a fresh watch-page bootstrap. It remains - /// collapsed and does not materialize the panel until the user expands it. + /// Replaces all prior state with a fresh watch-page bootstrap. The toolbar + /// action remains hidden until eligibility is known, and presenting the panel + /// materializes it lazily. func seed(_ bootstrap: YouTubeAskBootstrap?) { self.cancelCurrentOperation() self.bootstrap = bootstrap @@ -157,7 +158,6 @@ final class YouTubeAskViewModel { self.bootstrap = nil self.conversation = newConversation self.isAvailable = true - self.isExpanded = true self.requiresNewChat = false self.presentationError = nil self.finishOperation(generation: generation) diff --git a/Sources/Kaset/Views/YouTube/YouTubeAskPanelView.swift b/Sources/Kaset/Views/YouTube/YouTubeAskPanelView.swift index ef6822f67..ee4a69e18 100644 --- a/Sources/Kaset/Views/YouTube/YouTubeAskPanelView.swift +++ b/Sources/Kaset/Views/YouTube/YouTubeAskPanelView.swift @@ -1,46 +1,202 @@ import AppKit import SwiftUI +// MARK: - YouTubeAskToolbarButton + +struct YouTubeAskToolbarButton: View { + let viewModel: YouTubeAskViewModel + + var body: some View { + let accessibilityLabel = self.viewModel.isExpanded + ? String(localized: "Collapse Ask Gemini") + : String(localized: "Expand Ask Gemini") + + Button { + self.viewModel.toggleExpanded() + } label: { + Image(systemName: "sparkles") + .font(.system(size: 14)) + .foregroundStyle(.primary) + } + .help(accessibilityLabel) + .accessibilityLabel(accessibilityLabel) + .accessibilityIdentifier(AccessibilityID.YouTubeContent.askButton) + } +} + +// MARK: - YouTubeAskFloatingOverlay + +struct YouTubeAskFloatingOverlay: View { + let viewModel: YouTubeAskViewModel + + var body: some View { + if self.viewModel.isAvailable, self.viewModel.isExpanded { + GeometryReader { geometry in + ZStack { + Rectangle() + .fill(.clear) + .contentShape(Rectangle()) + .ignoresSafeArea() + .accessibilityIdentifier(AccessibilityID.YouTubeContent.askOverlay) + .onTapGesture { + self.viewModel.setExpanded(false) + } + + VStack(spacing: 0) { + YouTubeAskPanelView( + viewModel: self.viewModel, + maximumHeight: max(0, geometry.size.height - 32) + ) + .transition(.opacity.combined(with: .scale(scale: 0.95))) + + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .padding(.top, 16) + + YouTubeAskEscapeKeyMonitor { + self.viewModel.setExpanded(false) + } + .frame(width: 0, height: 0) + .accessibilityHidden(true) + } + } + .animation(.easeInOut(duration: 0.15), value: self.viewModel.isExpanded) + } + } +} + +// MARK: - YouTubeAskEscapeKeyMonitor + +private struct YouTubeAskEscapeKeyMonitor: NSViewRepresentable { + let dismiss: @MainActor () -> Void + + func makeCoordinator() -> Coordinator { + Coordinator(dismiss: self.dismiss) + } + + func makeNSView(context: Context) -> YouTubeAskEscapeMonitorView { + let view = YouTubeAskEscapeMonitorView(frame: .zero) + view.isHidden = true + view.windowDidChange = { [weak coordinator = context.coordinator] window in + coordinator?.window = window + } + context.coordinator.window = view.window + context.coordinator.install() + return view + } + + func updateNSView(_ view: YouTubeAskEscapeMonitorView, context: Context) { + context.coordinator.dismiss = self.dismiss + context.coordinator.window = view.window + } + + static func dismantleNSView(_ view: YouTubeAskEscapeMonitorView, coordinator: Coordinator) { + view.windowDidChange = nil + coordinator.window = nil + coordinator.uninstall() + } + + @MainActor + final class Coordinator { + var dismiss: @MainActor () -> Void + weak var window: NSWindow? + private var monitor: Any? + + init(dismiss: @escaping @MainActor () -> Void) { + self.dismiss = dismiss + } + + func install() { + guard self.monitor == nil else { return } + self.monitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + guard let self, + event.keyCode == 53, + event.window === self.window, + self.window?.isKeyWindow == true + else { + return event + } + self.dismiss() + return nil + } + } + + func uninstall() { + guard let monitor = self.monitor else { return } + NSEvent.removeMonitor(monitor) + self.monitor = nil + } + } +} + +// MARK: - YouTubeAskEscapeMonitorView + +@MainActor +private final class YouTubeAskEscapeMonitorView: NSView { + var windowDidChange: ((NSWindow?) -> Void)? + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + self.windowDidChange?(self.window) + } +} + // MARK: - YouTubeAskPanelView -/// Collapsible, watch-scoped Ask Gemini panel. It only presents server-issued +/// Floating, watch-scoped Ask Gemini panel. It only presents server-issued /// suggestions; free-form input is intentionally not part of this surface. struct YouTubeAskPanelView: View { let viewModel: YouTubeAskViewModel + let maximumHeight: CGFloat + + @Namespace private var askPanelNamespace + @FocusState private var isHeaderFocused: Bool var body: some View { - VStack(alignment: .leading, spacing: 12) { - self.header + CompatGlassContainer(spacing: 0) { + VStack(alignment: .leading, spacing: 12) { + self.header - Text( - "Responses are generated by YouTube and may be inaccurate.", - comment: "Disclosure shown in the YouTube Ask Gemini panel" - ) - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) + Text( + "Responses are generated by YouTube and may be inaccurate.", + comment: "Disclosure shown in the YouTube Ask Gemini panel" + ) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) - if self.viewModel.isExpanded { - Divider() - .opacity(0.4) + if self.viewModel.isExpanded { + Divider() + .opacity(0.4) - self.expandedContent + ScrollView(.vertical) { + self.expandedContent + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.trailing, 4) + } + .frame(maxHeight: self.scrollableContentHeight) + .scrollBounceBehavior(.basedOnSize) + } } + .padding(16) + .frame(width: 500) + .frame(maxHeight: self.maximumHeight) + .compatGlass(interactive: true, in: .rect(cornerRadius: 20)) + .compatGlassID("youtubeAskPanel", in: self.askPanelNamespace) } - .padding(14) - .compatGlass(in: .rect(cornerRadius: 14)) + .compatGlassTransition(.materialize) .accessibilityElement(children: .contain) .accessibilityIdentifier(AccessibilityID.YouTubeContent.askPanel) - .onChange(of: self.viewModel.accessibilityAnnouncementSequence) { _, _ in - guard let announcement = self.viewModel.accessibilityAnnouncement else { return } - self.postLiveRegionAnnouncement(self.accessibilityText(for: announcement)) - } - .onChange(of: self.viewModel.presentationError) { _, error in - guard let error else { return } - self.postLiveRegionAnnouncement(self.errorText(for: error)) + .onAppear { + self.isHeaderFocused = true } } + private var scrollableContentHeight: CGFloat { + max(0, min(520, self.maximumHeight - 128)) + } + private var header: some View { Button { self.viewModel.toggleExpanded() @@ -64,6 +220,7 @@ struct YouTubeAskPanelView: View { .contentShape(Rectangle()) } .buttonStyle(.plain) + .focused(self.$isHeaderFocused) .accessibilityLabel( self.viewModel.isExpanded ? String(localized: "Collapse Ask Gemini") @@ -196,7 +353,7 @@ struct YouTubeAskPanelView: View { } private func errorStatus(_ error: YouTubeAskPresentationError) -> some View { - Label(self.errorText(for: error), systemImage: "exclamationmark.triangle") + Label(YouTubeAskAccessibilityCopy.errorText(for: error), systemImage: "exclamationmark.triangle") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -213,8 +370,51 @@ struct YouTubeAskPanelView: View { String(localized: "Sending…") } } +} + +// MARK: - YouTube Ask Accessibility Announcements + +extension View { + func youtubeAskAccessibilityAnnouncements(viewModel: YouTubeAskViewModel) -> some View { + self.modifier(YouTubeAskAccessibilityAnnouncementsModifier(viewModel: viewModel)) + } +} - private func errorText(for error: YouTubeAskPresentationError) -> String { +// MARK: - YouTubeAskAccessibilityAnnouncementsModifier + +private struct YouTubeAskAccessibilityAnnouncementsModifier: ViewModifier { + let viewModel: YouTubeAskViewModel + + func body(content: Content) -> some View { + content + .onChange(of: self.viewModel.accessibilityAnnouncementSequence) { _, _ in + guard let announcement = self.viewModel.accessibilityAnnouncement else { return } + self.postLiveRegionAnnouncement( + YouTubeAskAccessibilityCopy.announcementText(for: announcement) + ) + } + .onChange(of: self.viewModel.presentationError) { _, error in + guard let error else { return } + self.postLiveRegionAnnouncement(YouTubeAskAccessibilityCopy.errorText(for: error)) + } + } + + private func postLiveRegionAnnouncement(_ message: String) { + NSAccessibility.post( + element: NSApplication.shared, + notification: .announcementRequested, + userInfo: [ + .announcement: message, + .priority: NSAccessibilityPriorityLevel.medium.rawValue, + ] + ) + } +} + +// MARK: - YouTubeAskAccessibilityCopy + +private enum YouTubeAskAccessibilityCopy { + static func errorText(for error: YouTubeAskPresentationError) -> String { switch error { case .authentication: String(localized: "Sign in again to use Ask Gemini.") @@ -227,7 +427,9 @@ struct YouTubeAskPanelView: View { } } - private func accessibilityText(for announcement: YouTubeAskViewModel.AccessibilityAnnouncement) -> String { + static func announcementText( + for announcement: YouTubeAskViewModel.AccessibilityAnnouncement + ) -> String { switch announcement { case .responseReady: String(localized: "Ask Gemini response ready") @@ -235,22 +437,13 @@ struct YouTubeAskPanelView: View { String(localized: "New Ask Gemini chat ready") } } - - private func postLiveRegionAnnouncement(_ message: String) { - NSAccessibility.post( - element: NSApplication.shared, - notification: .announcementRequested, - userInfo: [ - .announcement: message, - .priority: NSAccessibilityPriorityLevel.medium.rawValue, - ] - ) - } } // MARK: - AccessibilityID.YouTubeContent extension AccessibilityID.YouTubeContent { + static let askButton = "youtubeContent.askButton" + static let askOverlay = "youtubeContent.askOverlay" static let askPanel = "youtubeContent.askPanel" static let askToggle = "youtubeContent.askToggle" static let askTranscript = "youtubeContent.askTranscript" diff --git a/Sources/Kaset/Views/YouTube/YouTubeWatchView.swift b/Sources/Kaset/Views/YouTube/YouTubeWatchView.swift index 8878634c2..a05d2802e 100644 --- a/Sources/Kaset/Views/YouTube/YouTubeWatchView.swift +++ b/Sources/Kaset/Views/YouTube/YouTubeWatchView.swift @@ -87,13 +87,14 @@ struct YouTubeWatchView: View { } .frame(maxWidth: .infinity, alignment: .leading) - self.rightColumn + self.relatedColumn .frame(width: 360) } } .padding(.horizontal, 16) .padding(.vertical, 20) } + .disabled(self.viewModel.ask.isExpanded) // PROTOTYPE: full-bleed ambient color behind the page. `.ignoresSafeArea` // (inside the modifier) lets it bleed under the bottom player-bar inset, // so the bar's Liquid Glass capsule refracts the live color. @@ -108,6 +109,17 @@ struct YouTubeWatchView: View { .navigationTitle(String(localized: "")) // Let the ambient reach under the nav bar, like the other accent pages. .toolbarBackgroundVisibility(.hidden, for: .automatic) + .toolbar { + if self.viewModel.ask.isAvailable { + ToolbarItem(placement: .primaryAction) { + YouTubeAskToolbarButton(viewModel: self.viewModel.ask) + } + } + } + .overlay { + YouTubeAskFloatingOverlay(viewModel: self.viewModel.ask) + } + .youtubeAskAccessibilityAnnouncements(viewModel: self.viewModel.ask) #if DEBUG .toolbar { self.ambientStylePicker @@ -413,18 +425,6 @@ struct YouTubeWatchView: View { return currentTime < self.viewModel.data.chapters[nextIndex].startTime } - // MARK: - Right Column - - private var rightColumn: some View { - VStack(alignment: .leading, spacing: 16) { - if self.viewModel.ask.isAvailable { - YouTubeAskPanelView(viewModel: self.viewModel.ask) - } - - self.relatedColumn - } - } - // MARK: - Related Column private var relatedColumn: some View { diff --git a/Tests/KasetTests/YouTubeAskViewModelTests.swift b/Tests/KasetTests/YouTubeAskViewModelTests.swift index e8c621d2f..b90e24a09 100644 --- a/Tests/KasetTests/YouTubeAskViewModelTests.swift +++ b/Tests/KasetTests/YouTubeAskViewModelTests.swift @@ -5,8 +5,8 @@ import Testing @Suite("YouTube Ask view models", .serialized, .tags(.viewModel), .timeLimit(.minutes(1))) @MainActor struct YouTubeAskViewModelTests { - @Test("Ask remains collapsed and does not prepare until expanded") - func collapsedDefaultAndLazyPreparation() async { + @Test("Ask remains hidden and does not prepare until presented") + func hiddenDefaultAndLazyPreparation() async { let client = MockYouTubeClient() let bootstrap = YouTubeAskBootstrap.testing(suggestions: ["Explain the main idea"]) let sut = YouTubeAskViewModel(videoID: "fixture-video", client: client) @@ -27,6 +27,27 @@ struct YouTubeAskViewModelTests { #expect(client.continueAskConversationCallCount == 0) } + @Test("Dismissing and reopening preserves the prepared conversation") + func dismissalPreservesPreparedConversation() async { + let client = MockYouTubeClient() + let sut = YouTubeAskViewModel(videoID: "fixture-video", client: client) + sut.seed(YouTubeAskBootstrap.testing(suggestions: ["Explain the main idea"])) + + sut.setExpanded(true) + await self.waitUntil(sut.activity == .idle && !sut.suggestions.isEmpty) + + sut.setExpanded(false) + #expect(!sut.isExpanded) + #expect(sut.suggestions.map(\.text) == ["Explain the main idea"]) + + sut.setExpanded(true) + await Task.yield() + + #expect(sut.isExpanded) + #expect(client.loadAskConversationCallCount == 1) + #expect(sut.suggestions.map(\.text) == ["Explain the main idea"]) + } + @Test("Suggestion selection publishes the user turn, stays single-flight, and preserves server order") func selectionIsSingleFlightAndOrdered() async throws { let client = MockYouTubeClient() @@ -152,6 +173,47 @@ struct YouTubeAskViewModelTests { #expect(sut.accessibilityAnnouncement == .newChatReady) } + @Test("Dismissing during New Chat keeps the prepared replacement hidden") + func dismissalDuringNewChatPreservesVisibilityIntent() async throws { + let client = MockYouTubeClient() + let sut = YouTubeAskViewModel(videoID: "fixture-video", client: client) + sut.seed(YouTubeAskBootstrap.testing(suggestions: ["Initial question"])) + sut.setExpanded(true) + await self.waitUntil(sut.activity == .idle && !sut.suggestions.isEmpty) + + client.continuedAskConversation = YouTubeAskConversation.testing( + messages: [ + YouTubeAskMessage(role: .user, text: "Initial question"), + YouTubeAskMessage(role: .assistant, text: "Initial answer"), + ], + suggestions: ["Continue"] + ) + let initialSuggestion = try #require(sut.suggestions.first) + sut.selectSuggestion(id: initialSuggestion.id) + await self.waitUntil(sut.activity == .idle && sut.hasStarted) + + let gate = AsyncGate() + client.watchPages = [YouTubeWatchPage( + data: .empty, + askBootstrap: YouTubeAskBootstrap.testing(suggestions: ["Fresh question"]) + )] + client.askConversation = YouTubeAskConversation.testing(suggestions: ["Fresh question"]) + client.beforeAskPreparationReturn = { + await gate.wait() + } + + sut.startNewChat() + await self.waitUntil(sut.activity == .preparing && client.loadAskConversationCallCount == 2) + sut.setExpanded(false) + + await gate.open() + await self.waitUntil(sut.activity == .idle && sut.suggestions.map(\.text) == ["Fresh question"]) + + #expect(!sut.isExpanded) + #expect(sut.presentationError == nil) + #expect(sut.accessibilityAnnouncement == .newChatReady) + } + @Test("A repeated watch task preserves the active conversation") func repeatedWatchTaskPreservesConversation() async throws { let client = MockYouTubeClient() @@ -393,7 +455,7 @@ struct YouTubeAskViewModelTests { #expect(sut.suggestions.isEmpty) } - @Test("Watch load uses one watch-page response and seeds a collapsed child") + @Test("Watch load seeds an available but hidden Ask panel") func watchViewModelSeedsAskFromWatchPage() async { let client = MockYouTubeClient() client.watchNextData = WatchNextData( diff --git a/docs/adr/0032-youtube-ask-gemini.md b/docs/adr/0032-youtube-ask-gemini.md index fe7333fca..0aeb6d495 100644 --- a/docs/adr/0032-youtube-ask-gemini.md +++ b/docs/adr/0032-youtube-ask-gemini.md @@ -37,9 +37,12 @@ Wire-level observations and the API Explorer workflow remain documented in the `next` response is shared with normal watch-page parsing. WebViews remain limited to authentication and DRM playback; Kaset does not scrape or drive the Ask Gemini DOM. -2. **Ship a chips-only v1.** The watch page may show a collapsed Ask Gemini card - above Related. Expanding it may prepare the initial panel, but never submits a - suggestion or generates an answer automatically. Only server-issued +2. **Ship a chips-only v1.** An eligible watch page exposes a sparkles action + in the top toolbar. Activating it presents a transient, top-centered glass panel + and may prepare the initial panel, but never submits a suggestion or generates + an answer automatically. Outside click, Escape, or the panel header + dismisses the surface without discarding the current watch-scoped conversation. + Only server-issued suggestion chips and follow-up chips can be selected. Arbitrary text prompts, a text composer, and `streaming_panel` are out of scope. 3. **Scope all conversation state to the current watch and account.** Ask is diff --git a/docs/testing.md b/docs/testing.md index 6fbc57c95..77055e587 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -544,6 +544,8 @@ Before releasing: - [ ] Re-opening window doesn't duplicate audio - [ ] Sign out and re-login works - [ ] Ask Gemini remains absent while no request profile has passed parity +- [ ] Eligible YouTube watch pages show a toolbar sparkles action, not an inline card +- [ ] The Ask panel opens lazily and dismisses by outside click and Escape without losing the chat ### Simulating Auth Expiry (Runtime Debugging) diff --git a/docs/youtube.md b/docs/youtube.md index b50de5862..e652cbe41 100644 --- a/docs/youtube.md +++ b/docs/youtube.md @@ -113,9 +113,10 @@ Kaset accepts text and follow-up chips only from the confirmed inserted `youChatItemViewModel` contents, while generated result/link objects remain non-interactive and undisplayed. -The Ask implementation is intentionally chips-only: expanding the collapsed -card may prepare an initial panel, but it never generates an answer until the -user selects a server-issued suggestion. Follow-up chips are also server-issued; +The Ask implementation is intentionally chips-only: opening the watch-page +toolbar panel may prepare an initial panel, but it never generates an answer +until the user selects a server-issued suggestion. Follow-up chips are also +server-issued; there is no free-text composer or `streaming_panel` path. Visible labels and answers are sanitized but not localized by Kaset. Assistant messages render native Markdown blocks and inline emphasis; link destinations are stripped and @@ -241,10 +242,13 @@ natively. Comments come from the watch page's `comment-item-section` continuatio tokens), expandable reply threads, and author → channel navigation. When enabled by a validated request profile and an eligible watch response, Ask -Gemini appears as a collapsed card above Related. It discloses that YouTube -generates the responses, prepares lazily, shows a height-bounded selectable -transcript, disables all chips during a single in-flight request, and offers New -Chat after the first turn or when a submission outcome is uncertain. The +Gemini appears as a sparkles action in the top toolbar. Activating it presents a +top-centered floating glass panel while leaving Related in place. The panel +discloses that YouTube generates the responses, prepares lazily, shows a +height-bounded selectable transcript, disables all chips during a single +in-flight request, and offers New Chat after the first turn or when a submission +outcome is uncertain. Outside click, Escape, or the panel header dismisses the +panel without discarding its current watch-scoped conversation. The conversation is owned by the current watch view—not `YouTubeViewModelStore`—and is discarded on navigation, source/account/authentication changes, cancellation, or app termination. From cd4f9ba99857dc4d486c71bbfb010979b8956e0c Mon Sep 17 00:00:00 2001 From: Sertac Ozercan Date: Sat, 1 Aug 2026 22:16:46 -0700 Subject: [PATCH 10/18] feat(youtube): add free-text Ask Gemini Signed-off-by: Sertac Ozercan --- CONTEXT.md | 9 +- Sources/APIExplorer/AskVideoAudit.swift | 564 ++++++++++++++++++ Sources/APIExplorer/main.swift | 181 +++++- .../Models/YouTube/YouTubeAskModels.swift | 114 +++- Sources/Kaset/Resources/Localizable.xcstrings | 190 ++++++ .../Resources/ar.lproj/Localizable.strings | 2 + .../Resources/de.lproj/Localizable.strings | 2 + .../Resources/en.lproj/Localizable.strings | 2 + .../Resources/es.lproj/Localizable.strings | 2 + .../Resources/fr.lproj/Localizable.strings | 2 + .../Resources/id.lproj/Localizable.strings | 2 + .../Resources/it.lproj/Localizable.strings | 2 + .../Resources/ko.lproj/Localizable.strings | 2 + .../Resources/nl.lproj/Localizable.strings | 2 + .../Resources/pl.lproj/Localizable.strings | 2 + .../Resources/pt.lproj/Localizable.strings | 2 + .../Resources/ru.lproj/Localizable.strings | 2 + .../Resources/sv.lproj/Localizable.strings | 2 + .../Resources/tr.lproj/Localizable.strings | 2 + .../Resources/uk.lproj/Localizable.strings | 2 + .../API/MockUITestYouTubeClient.swift | 31 +- .../Services/API/YouTubeClient+Ask.swift | 81 ++- .../Kaset/Services/API/YouTubeClient.swift | 16 +- Sources/Kaset/Services/YouTubeProtocols.swift | 8 + .../Kaset/Utilities/MainWindowLayout.swift | 2 + .../YouTube/YouTubeAskViewModel.swift | 72 +++ Sources/Kaset/Views/MainWindow.swift | 6 +- .../Views/YouTube/YouTubeAskPanelView.swift | 292 ++++++--- .../Views/YouTube/YouTubeWatchView.swift | 14 +- .../YouTubeAskCore/YouTubeAskCoreError.swift | 3 + Sources/YouTubeAskCore/YouTubeAskLimits.swift | 3 + .../YouTubeAskOpaqueCommand.swift | 11 + .../YouTubeAskParsedModels.swift | 1 + .../YouTubeAskParser+FreeText.swift | 93 +++ .../YouTubeAskParser+Suggestions.swift | 40 ++ .../YouTubeAskParser+VisibleText.swift | 45 ++ Sources/YouTubeAskCore/YouTubeAskParser.swift | 113 ++-- .../YouTubeAskRequestBuilder.swift | 75 +++ Tests/KasetTests/AppLocalizationTests.swift | 2 + .../Helpers/MockYouTubeClient.swift | 32 +- Tests/KasetTests/MainWindowLayoutTests.swift | 5 + .../YouTubeAskClientFreeTextTests.swift | 89 +++ Tests/KasetTests/YouTubeAskClientTests.swift | 17 +- .../KasetTests/YouTubeAskViewModelTests.swift | 59 ++ .../YouTubeSingleFlightViewModelTests.swift | 8 + .../Fixtures/YouTubeAskEligibleNext.json | 9 + .../YouTubeAskFixtureSafetyTests.swift | 96 +++ .../YouTubeAskParserMirroredPanelTests.swift | 141 +++++ .../YouTubeAskParserTests.swift | 90 ++- .../YouTubeAskRequestBuilderTests.swift | 75 +++ docs/adr/0032-youtube-ask-gemini.md | 27 +- docs/api-discovery.md | 73 ++- docs/architecture.md | 2 +- docs/testing.md | 6 +- docs/youtube.md | 26 +- 55 files changed, 2515 insertions(+), 236 deletions(-) create mode 100644 Sources/YouTubeAskCore/YouTubeAskParser+FreeText.swift create mode 100644 Sources/YouTubeAskCore/YouTubeAskParser+Suggestions.swift create mode 100644 Sources/YouTubeAskCore/YouTubeAskParser+VisibleText.swift create mode 100644 Tests/KasetTests/YouTubeAskClientFreeTextTests.swift create mode 100644 Tests/YouTubeAskCoreTests/YouTubeAskParserMirroredPanelTests.swift diff --git a/CONTEXT.md b/CONTEXT.md index 289d5d551..cea38bb2f 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -54,7 +54,7 @@ The workflows that turn playlist browse data into native playback queues. This i ## YouTube Ask -The watch-page Ask Gemini capability in the regular YouTube experience. Kaset's first version is limited to server-issued suggestion chips and follow-up chips. It uses YouTube APIs rather than the playback WebView, selects the fixed WEB request profile explicitly in production, and appears only when YouTube returns an eligible bootstrap for a signed-in primary account. +The watch-page Ask Gemini capability in the regular YouTube experience. Kaset supports server-issued suggestion chips plus one server-commanded free-text turn per fresh chat. It uses YouTube APIs rather than the playback WebView, selects the fixed WEB request profile explicitly in production, and appears only when YouTube returns an eligible bootstrap for a signed-in primary account. ## Ask Bootstrap @@ -62,12 +62,17 @@ The eligible YouChat material discovered in the current video's watch-page `next ## Ask Conversation -The memory-only visible transcript and server-issued follow-up suggestions for one watch-scoped Ask chat. New Chat replaces it only after a fresh bootstrap and panel preparation succeed. Navigation, account/authentication changes, cancellation, or app termination discard it. +The memory-only visible transcript, one-shot free-text capability, and server-issued follow-up suggestions for one watch-scoped Ask chat. New Chat replaces it only after a fresh bootstrap and panel preparation succeed. Navigation, account/authentication changes, cancellation, or app termination discard it. ## Server-Issued Ask Suggestion A sanitized visible chip label paired internally with an opaque server command. The label is displayed verbatim and is not a localized Kaset string. Selecting the suggestion replays its exact command; Kaset never turns the label into free-form input. + +## Server-Issued Ask Free-Text Command + +The opaque `sendUserQueryCommand` found in the canonical eligible YouChat panel. Kaset may use it once per fresh chat with the validated `get_panel` form fields and current playback offset. Its continuation and click-tracking values remain in memory and are never printed or persisted. + ## Opaque Ask Command Unprintable, non-persistent server state used only to continue the current Ask conversation. It must never be logged, cached, serialized, restored, placed in fixtures, exposed through accessibility identifiers, or reused across a video, account, conversation, or revision boundary. diff --git a/Sources/APIExplorer/AskVideoAudit.swift b/Sources/APIExplorer/AskVideoAudit.swift index f0d44b77a..9b5cb6ab5 100644 --- a/Sources/APIExplorer/AskVideoAudit.swift +++ b/Sources/APIExplorer/AskVideoAudit.swift @@ -1217,6 +1217,570 @@ private func makeAskSuggestionRequest( return response } +// MARK: - AskFreeTextValidationError + +private enum AskFreeTextValidationError: LocalizedError { + case commandUnavailable + case malformedCommand + case ambiguousCommand + case requestEncodingFailed + case requestSnapshotUnavailable + case requestSnapshotChanged + case nextRequestFailed + case nextHTTPFailure(Int) + case streamingRequestFailed + case streamingHTTPFailure(Int) + case responseTooLarge + case responseDecodeFailed + case responseParseFailed + case answerUnavailable + + var errorDescription: String? { + switch self { + case .commandUnavailable: + "No eligible PAyouchat free-text command was available" + case .malformedCommand: + "The eligible PAyouchat free-text command did not match the exact supported schema" + case .ambiguousCommand: + "Multiple distinct eligible PAyouchat free-text commands were present" + case .requestEncodingFailed: + "The fixed free-text request body could not be encoded" + case .requestSnapshotUnavailable: + "The authenticated WEB request snapshot could not be prepared" + case .requestSnapshotChanged: + "The authenticated WEB request snapshot changed before the private prompt could be sent" + case .nextRequestFailed: + "The authenticated watch bootstrap request failed" + case let .nextHTTPFailure(statusCode): + "The authenticated watch bootstrap returned HTTP \(statusCode)" + case .streamingRequestFailed: + "The one-shot free-text request failed" + case let .streamingHTTPFailure(statusCode): + "The one-shot free-text request returned HTTP \(statusCode)" + case .responseTooLarge: + "The one-shot free-text response exceeded the safety limit" + case .responseDecodeFailed: + "The one-shot free-text response could not be decoded safely" + case .responseParseFailed: + "The one-shot free-text response did not match a confirmed YouChat response container" + case .answerUnavailable: + "The one-shot free-text response did not contain visible assistant text" + } + } +} + +// MARK: - AskFreeTextCookieState + +private struct AskFreeTextCookieState: Equatable, CustomStringConvertible, CustomDebugStringConvertible { + let name: String + let value: String + let domain: String + let path: String + let expiresAt: TimeInterval? + let isSecure: Bool + let isHTTPOnly: Bool + let isSessionOnly: Bool + + var description: String { + "" + } + + var debugDescription: String { + self.description + } + + var sortComponents: [String] { + [ + self.domain, + self.path, + self.name, + self.value, + self.expiresAt.map { String($0) } ?? "", + self.isSecure ? "1" : "0", + self.isHTTPOnly ? "1" : "0", + self.isSessionOnly ? "1" : "0", + ] + } +} + +// MARK: - AskFreeTextAccountState + +private struct AskFreeTextAccountState: Equatable, CustomStringConvertible, CustomDebugStringConvertible { + let youtubeMode: Bool + let apiHost: String + let webClientURL: String + let baseURL: String + let origin: String + let clientName: String + let authUserIndex: Int + let authUserOptionWasSpecified: Bool + let brandAccountID: String? + let forceUnauthenticatedRequests: Bool + let clientVersionWasForced: Bool + + var description: String { + "" + } + + var debugDescription: String { + self.description + } +} + +// MARK: - AskFreeTextBackingState + +private struct AskFreeTextBackingState: Equatable, CustomStringConvertible, CustomDebugStringConvertible { + let account: AskFreeTextAccountState + let cookies: [AskFreeTextCookieState] + + var description: String { + "" + } + + var debugDescription: String { + self.description + } +} + +// MARK: - AskFreeTextRequestSnapshot + +private struct AskFreeTextRequestSnapshot: CustomStringConvertible, CustomDebugStringConvertible { + let baseURL: String + let runtimeAPIIdentifier: String + let contextData: Data + let headers: [String: String] + let backingState: AskFreeTextBackingState + + var description: String { + "" + } + + var debugDescription: String { + self.description + } +} + +private func currentAskFreeTextAccountState() -> AskFreeTextAccountState { + AskFreeTextAccountState( + youtubeMode: youtubeMode, + apiHost: activeAPIHost, + webClientURL: activeWebClientURL.absoluteString, + baseURL: activeBaseURL, + origin: activeOrigin, + clientName: activeClientName, + authUserIndex: globalAuthUserIndex, + authUserOptionWasSpecified: authUserOptionWasSpecified, + brandAccountID: globalBrandAccountId, + forceUnauthenticatedRequests: forceUnauthenticatedRequests, + clientVersionWasForced: clientVersionWasForced + ) +} + +private func askFreeTextCookieMatchesHost(_ cookie: HTTPCookie, host: String) -> Bool { + let domain = cookie.domain.lowercased() + if domain.hasPrefix(".") { + let suffix = String(domain.dropFirst()) + return host == suffix || host.hasSuffix(".\(suffix)") + } + return host == domain || host.hasSuffix(".\(domain)") +} + +private func askFreeTextCookieState( + cookies: [HTTPCookie], + host: String +) -> [AskFreeTextCookieState] { + cookies + .filter { askFreeTextCookieMatchesHost($0, host: host) } + .map { cookie in + AskFreeTextCookieState( + name: cookie.name, + value: cookie.value, + domain: cookie.domain, + path: cookie.path, + expiresAt: cookie.expiresDate?.timeIntervalSinceReferenceDate, + isSecure: cookie.isSecure, + isHTTPOnly: cookie.isHTTPOnly, + isSessionOnly: cookie.isSessionOnly + ) + } + .sorted { lhs, rhs in + lhs.sortComponents.lexicographicallyPrecedes(rhs.sortComponents) + } +} + +private func currentAskFreeTextBackingState() throws -> AskFreeTextBackingState { + let account = currentAskFreeTextAccountState() + guard account.youtubeMode, + !account.forceUnauthenticatedRequests, + !account.authUserOptionWasSpecified, + account.authUserIndex == 0, + account.brandAccountID == nil, + !account.clientVersionWasForced, + let cookies = loadCookiesFromAppBackup(), + getSAPISID(from: cookies) != nil, + buildCookieHeader(from: cookies) != nil + else { + throw AskFreeTextValidationError.requestSnapshotUnavailable + } + return AskFreeTextBackingState( + account: account, + cookies: askFreeTextCookieState(cookies: cookies, host: account.apiHost) + ) +} + +private func askFreeTextContextData( + runtimeConfiguration: AskRuntimeWEBConfiguration, + account: AskFreeTextAccountState +) throws -> Data { + let context: [String: Any] = [ + "client": [ + "clientName": account.clientName, + "clientVersion": runtimeConfiguration.clientVersion, + "hl": "en", + "gl": "US", + "browserName": "Safari", + "browserVersion": "17.0", + "osName": "Macintosh", + "osVersion": "10_15_7", + "platform": "DESKTOP", + ], + "user": ["lockedSafetyMode": false], + ] + return try JSONSerialization.data(withJSONObject: context) +} + +private func askFreeTextHeaders( + cookies: [HTTPCookie], + account: AskFreeTextAccountState +) throws -> [String: String] { + guard let cookieHeader = buildCookieHeader(from: cookies), + let authorization = buildSIDAuthorizationHeader( + from: cookies, + includeAllAvailableProofs: false + ) + else { + throw AskFreeTextValidationError.requestSnapshotUnavailable + } + return [ + "Content-Type": "application/json", + "User-Agent": askParityUserAgent, + "Origin": account.origin, + "Referer": "\(account.origin)/", + "Cookie": cookieHeader, + "Authorization": authorization, + "X-Goog-AuthUser": String(account.authUserIndex), + "X-Origin": account.origin, + ] +} + +private func captureAskFreeTextRequestSnapshot() async throws -> AskFreeTextRequestSnapshot { + let initialBackingState = try currentAskFreeTextBackingState() + guard let cookies = loadCookiesFromAppBackup(), + currentAskFreeTextAccountState() == initialBackingState.account, + askFreeTextCookieState( + cookies: cookies, + host: initialBackingState.account.apiHost + ) == initialBackingState.cookies + else { + throw AskFreeTextValidationError.requestSnapshotChanged + } + + let runtimeConfiguration: AskRuntimeWEBConfiguration + do { + runtimeConfiguration = try await resolveAskRuntimeWEBConfiguration(cookies: cookies) + } catch { + throw AskFreeTextValidationError.requestSnapshotUnavailable + } + + // Configuration discovery can await network I/O. Re-read the authoritative + // cookie export and account selectors before sealing the snapshot so a + // concurrent logout or account change cannot produce a mixed request. + let finalBackingState: AskFreeTextBackingState + do { + finalBackingState = try currentAskFreeTextBackingState() + } catch { + throw AskFreeTextValidationError.requestSnapshotChanged + } + guard finalBackingState == initialBackingState else { + throw AskFreeTextValidationError.requestSnapshotChanged + } + + let contextData: Data + let headers: [String: String] + do { + contextData = try askFreeTextContextData( + runtimeConfiguration: runtimeConfiguration, + account: initialBackingState.account + ) + headers = try askFreeTextHeaders( + cookies: cookies, + account: initialBackingState.account + ) + } catch let error as AskFreeTextValidationError { + throw error + } catch { + throw AskFreeTextValidationError.requestSnapshotUnavailable + } + + return AskFreeTextRequestSnapshot( + baseURL: initialBackingState.account.baseURL, + runtimeAPIIdentifier: runtimeConfiguration.runtimeAPIIdentifier, + contextData: contextData, + headers: headers, + backingState: initialBackingState + ) +} + +private func validateAskFreeTextRequestSnapshot( + _ snapshot: AskFreeTextRequestSnapshot +) throws { + let currentState: AskFreeTextBackingState + do { + currentState = try currentAskFreeTextBackingState() + } catch { + throw AskFreeTextValidationError.requestSnapshotChanged + } + guard currentState == snapshot.backingState else { + throw AskFreeTextValidationError.requestSnapshotChanged + } +} + +private func makeRuntimeAskFreeTextWireRequest( + endpoint: String, + bodyData: Data, + requestSnapshot: AskFreeTextRequestSnapshot, + clickTrackingContextData: Data? = nil, + validateBackingStateBeforeSending: Bool = false +) async throws -> APIWireResponse { + let endpoint = try canonicalAPIEndpoint(endpoint) + guard var body = try JSONSerialization.jsonObject(with: bodyData) as? [String: Any], + let snapshotContext = try JSONSerialization.jsonObject( + with: requestSnapshot.contextData + ) as? [String: Any] + else { + throw AskFreeTextValidationError.requestEncodingFailed + } + var components = URLComponents(string: "\(requestSnapshot.baseURL)/\(endpoint)") + components?.queryItems = [ + URLQueryItem(name: "key", value: requestSnapshot.runtimeAPIIdentifier), + URLQueryItem(name: "prettyPrint", value: "false"), + ] + guard let url = components?.url else { + throw AskFreeTextValidationError.requestEncodingFailed + } + + var context = snapshotContext + if let clickTrackingContextData { + guard let clickTrackingContext = try JSONSerialization.jsonObject( + with: clickTrackingContextData + ) as? [String: Any], + Set(clickTrackingContext.keys) == ["clickTracking"], + let clickTracking = clickTrackingContext["clickTracking"] as? [String: Any], + Set(clickTracking.keys) == ["clickTrackingParams"] + else { + throw AskFreeTextValidationError.requestEncodingFailed + } + context["clickTracking"] = clickTracking + } + body["context"] = context + + var request = URLRequest( + url: url, + cachePolicy: .reloadIgnoringLocalCacheData, + timeoutInterval: 30 + ) + request.httpMethod = "POST" + request.httpShouldHandleCookies = false + for (key, value) in requestSnapshot.headers { + request.setValue(value, forHTTPHeaderField: key) + } + let finalBody = try JSONSerialization.data(withJSONObject: body) + try YouTubeAskRequestBuilder.validateRequestBodySize(finalBody) + request.httpBody = finalBody + + if validateBackingStateBeforeSending { + try validateAskFreeTextRequestSnapshot(requestSnapshot) + } + + let (data, response) = try await boundedResponseData( + configuration: .ephemeral, + request: request, + maximumBytes: YouTubeAskLimits.maximumResponseBytes + ) + guard let httpResponse = response as? HTTPURLResponse else { + throw AskFreeTextValidationError.streamingRequestFailed + } + return APIWireResponse( + data: data, + statusCode: httpResponse.statusCode, + contentType: httpResponse.value(forHTTPHeaderField: "Content-Type") + ) +} + +private func loadAskFreeTextCommand( + videoID: String, + requestSnapshot: AskFreeTextRequestSnapshot +) async throws -> YouTubeAskOpaqueCommand { + guard let nextBody = try? JSONSerialization.data(withJSONObject: ["videoId": videoID]) else { + throw AskFreeTextValidationError.requestEncodingFailed + } + let response: APIWireResponse + do { + response = try await makeRuntimeAskFreeTextWireRequest( + endpoint: "next", + bodyData: nextBody, + requestSnapshot: requestSnapshot + ) + } catch is ResponseSizeLimitError { + throw AskFreeTextValidationError.responseTooLarge + } catch { + throw AskFreeTextValidationError.nextRequestFailed + } + guard (200 ... 299).contains(response.statusCode) else { + throw AskFreeTextValidationError.nextHTTPFailure(response.statusCode) + } + + let envelope: YouTubeAskWireEnvelope + do { + envelope = try YouTubeAskWireDecoder.decode(response.data) + } catch { + throw AskFreeTextValidationError.responseDecodeFailed + } + + let bootstrap: YouTubeAskParsedBootstrap? + do { + bootstrap = try YouTubeAskParser.parseBootstrap(from: envelope) + } catch YouTubeAskCoreError.ambiguousBootstrap { + throw AskFreeTextValidationError.ambiguousCommand + } catch { + throw AskFreeTextValidationError.malformedCommand + } + guard let command = bootstrap?.freeTextCommand else { + throw AskFreeTextValidationError.commandUnavailable + } + return command +} + +private func sendAskFreeTextRequest( + command: YouTubeAskOpaqueCommand, + prompt: String, + requestSnapshot: AskFreeTextRequestSnapshot +) async throws -> APIWireResponse { + let bodyData: Data + let clickTrackingContextData: Data + do { + let milliseconds = max(0, Int64(Date().timeIntervalSince1970 * 1000)) + bodyData = try YouTubeAskRequestBuilder.makeFreeTextBody( + command: command, + clientMessageID: "youchat-\(milliseconds)", + userInputText: prompt, + playerOffsetMilliseconds: 0 + ) + clickTrackingContextData = try YouTubeAskRequestBuilder.makeFreeTextClickTrackingContext( + command: command + ) + } catch { + throw AskFreeTextValidationError.requestEncodingFailed + } + do { + return try await makeRuntimeAskFreeTextWireRequest( + endpoint: "get_panel", + bodyData: bodyData, + requestSnapshot: requestSnapshot, + clickTrackingContextData: clickTrackingContextData, + validateBackingStateBeforeSending: true + ) + } catch is ResponseSizeLimitError { + throw AskFreeTextValidationError.responseTooLarge + } catch let error as AskFreeTextValidationError { + throw error + } catch { + throw AskFreeTextValidationError.streamingRequestFailed + } +} + +func liveTestAskVideoFreeText( + _ videoID: String, + prompt: String +) async { + guard isValidYouTubeVideoID(videoID) else { + print("❌ Invalid YouTube video ID") + return + } + if !youtubeMode { + activateYouTubeMode() + } + guard !forceUnauthenticatedRequests else { + print("❌ ask-video-free-text-test requires authentication; remove --guest/--no-auth") + return + } + guard !authUserOptionWasSpecified, globalAuthUserIndex == 0, globalBrandAccountId == nil else { + print("❌ ask-video-free-text-test supports only the signed-in primary account") + return + } + guard let cookies = loadCookiesFromAppBackup(), + getSAPISID(from: cookies) != nil, + buildCookieHeader(from: cookies) != nil + else { + print("❌ No usable Kaset cookie export is available") + return + } + + print("🧪 Ask Gemini guarded free-text test") + print("====================================\n") + print("Video ID: validated (value not displayed)") + print("Prompt: loaded privately (\(prompt.count) characters; content not displayed)") + print("Safety: one server-commanded get_panel request; no retry or raw output") + print("Request profile: runtime WEB configuration validated by the read-only Ask audit") + + do { + let requestSnapshot = try await captureAskFreeTextRequestSnapshot() + let command = try await loadAskFreeTextCommand( + videoID: videoID, + requestSnapshot: requestSnapshot + ) + print("Eligible PAyouchat free-text command: validated (opaque values hidden)") + + let response = try await sendAskFreeTextRequest( + command: command, + prompt: prompt, + requestSnapshot: requestSnapshot + ) + printAskLiveWireSummary(response, verbose: false) + guard (200 ... 299).contains(response.statusCode) else { + throw AskFreeTextValidationError.streamingHTTPFailure(response.statusCode) + } + + let envelope: YouTubeAskWireEnvelope + do { + envelope = try YouTubeAskWireDecoder.decode(response.data) + } catch { + throw AskFreeTextValidationError.responseDecodeFailed + } + + let conversation: YouTubeAskParsedConversation + do { + conversation = try YouTubeAskParser.parseConversation(from: envelope) + } catch { + throw AskFreeTextValidationError.responseParseFailed + } + guard !conversation.messages.isEmpty else { + throw AskFreeTextValidationError.answerUnavailable + } + + print("\nAssistant response:") + for message in conversation.messages { + print(renderedAskLiveMessage(message)) + } + print("\nServer-issued follow-up suggestions: \(conversation.suggestions.count)") + print("Opaque command and conversation values were not displayed or saved") + } catch let error as AskFreeTextValidationError { + print("❌ Free-text validation failed: \(error.localizedDescription)") + } catch { + print("❌ Free-text validation failed safely; no raw values were displayed") + } +} + private func makeAskSummaryRequest( videoId: String, selection: AskParitySelection diff --git a/Sources/APIExplorer/main.swift b/Sources/APIExplorer/main.swift index 294ea6360..737e23251 100755 --- a/Sources/APIExplorer/main.swift +++ b/Sources/APIExplorer/main.swift @@ -16,6 +16,8 @@ // ask-video-audit - Audit YouTube Ask Gemini / YouChat API surfaces // ask-video-parity - Compare read-only Ask request profiles // ask-video-live-test - Replay server-issued summary/follow-up suggestions +// ask-video-free-text-test +// - Guarded one-shot free-text validation // search-audit - Audit live YouTube Music search shapes and filters // continuation [ep] - Explore a continuation (ep: browse, search, or next) // analyze-file - Safely summarize a saved JSON response @@ -28,6 +30,7 @@ // -o, --output - Save raw JSON response to a file // --client-version - Override the resolved InnerTube client version // --confirm-live-ai - Required acknowledgement for live AI requests +// --prompt-file - Read a private free-text prompt from a mode-0600 file or stdin // --fresh-chats N - Run 1-3 independent summary chats // --follow-up - Replay one server-issued follow-up suggestion // --youtube, --yt - Target regular YouTube (www.youtube.com, WEB client) @@ -41,6 +44,7 @@ // swift run api-explorer action search '{"query":"never gonna give you up"}' // swift run api-explorer ask-video-parity // swift run api-explorer ask-video-live-test --confirm-live-ai --follow-up +// swift run api-explorer ask-video-free-text-test --confirm-live-ai --prompt-file - // swift run api-explorer continuation next # Mix queue continuation // swift run api-explorer auth // swift run api-explorer list @@ -1770,6 +1774,8 @@ func exploreAction( } private let maximumPrivateBodyBytes = 2 * 1024 * 1024 +private let maximumPrivatePromptBytes = 64 * 1024 +private let maximumPrivatePromptCharacters = 16000 private func posixError(_ description: String, code: Int32 = errno) -> NSError { NSError( @@ -1924,16 +1930,23 @@ func writePrivateOutput(_ data: Data, to path: String) throws { stagingFileExists = false } -private func readBoundedData(fileDescriptor: Int32) throws -> Data { +private func readBoundedData( + fileDescriptor: Int32, + maximumBytes: Int = maximumPrivateBodyBytes, + contentDescription: String = "Request body" +) throws -> Data { var result = Data() var buffer = [UInt8](repeating: 0, count: 64 * 1024) while true { - let maximumRead = min(buffer.count, maximumPrivateBodyBytes + 1 - result.count) + let maximumRead = min(buffer.count, maximumBytes + 1 - result.count) guard maximumRead > 0 else { throw NSError( domain: "APIExplorer", code: -1, - userInfo: [NSLocalizedDescriptionKey: "Request body exceeds 2 MiB"] + userInfo: [ + NSLocalizedDescriptionKey: + "\(contentDescription) exceeds its size limit", + ] ) } let readCount = buffer.withUnsafeMutableBytes { rawBuffer in @@ -1949,17 +1962,118 @@ private func readBoundedData(fileDescriptor: Int32) throws -> Data { throw posixError("Could not read request body") } result.append(buffer, count: readCount) - if result.count > maximumPrivateBodyBytes { + if result.count > maximumBytes { throw NSError( domain: "APIExplorer", code: -1, - userInfo: [NSLocalizedDescriptionKey: "Request body exceeds 2 MiB"] + userInfo: [ + NSLocalizedDescriptionKey: + "\(contentDescription) exceeds its size limit", + ] ) } } return result } +func loadPrivatePrompt(from promptFile: String) throws -> String { + let data: Data + if promptFile == "-" { + guard Darwin.isatty(STDIN_FILENO) == 0 else { + throw NSError( + domain: "APIExplorer", + code: -1, + userInfo: [ + NSLocalizedDescriptionKey: + "Interactive stdin is not accepted; pipe or redirect the prompt instead", + ] + ) + } + data = try readBoundedData( + fileDescriptor: STDIN_FILENO, + maximumBytes: maximumPrivatePromptBytes, + contentDescription: "Prompt" + ) + } else { + let expandedPath = NSString(string: promptFile).expandingTildeInPath + let fileDescriptor = expandedPath.withCString { + Darwin.open($0, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK) + } + guard fileDescriptor >= 0 else { + throw posixError("Could not open private prompt file") + } + defer { _ = Darwin.close(fileDescriptor) } + + var status = stat() + guard fstat(fileDescriptor, &status) == 0 else { + throw posixError("Could not inspect private prompt file") + } + guard (status.st_mode & S_IFMT) == S_IFREG else { + throw NSError( + domain: "APIExplorer", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Prompt path must be a regular file"] + ) + } + guard status.st_uid == geteuid(), + status.st_mode & 0o777 == S_IRUSR | S_IWUSR + else { + throw NSError( + domain: "APIExplorer", + code: -1, + userInfo: [ + NSLocalizedDescriptionKey: + "Prompt file must be owned by the current user with mode 0600", + ] + ) + } + guard try !extendedACLStatus(fileDescriptor: fileDescriptor) else { + throw NSError( + domain: "APIExplorer", + code: -1, + userInfo: [ + NSLocalizedDescriptionKey: + "Prompt file must not have an extended ACL (use chmod -N)", + ] + ) + } + guard status.st_size >= 0, + status.st_size <= off_t(maximumPrivatePromptBytes) + else { + throw NSError( + domain: "APIExplorer", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Prompt exceeds its size limit"] + ) + } + data = try readBoundedData( + fileDescriptor: fileDescriptor, + maximumBytes: maximumPrivatePromptBytes, + contentDescription: "Prompt" + ) + } + + guard var prompt = String(data: data, encoding: .utf8) else { + throw NSError( + domain: "APIExplorer", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Prompt must be valid UTF-8"] + ) + } + prompt = prompt.trimmingCharacters(in: .whitespacesAndNewlines) + guard !prompt.isEmpty, prompt.count <= maximumPrivatePromptCharacters else { + throw NSError( + domain: "APIExplorer", + code: -1, + userInfo: [ + NSLocalizedDescriptionKey: + "Prompt must contain 1-\(maximumPrivatePromptCharacters) characters", + ] + ) + } + return prompt +} + func loadRequestBodyJSON(inlineBody: String?, bodyFile: String?) throws -> String { if inlineBody != nil, bodyFile != nil { throw NSError( @@ -3286,6 +3400,8 @@ func showHelp() { ask-video-audit Audit Ask Gemini / YouChat without sending a prompt ask-video-parity Compare ordered read-only Ask request profiles ask-video-live-test Replay the server-issued summary suggestion + ask-video-free-text-test + Validate one server-commanded free-text request search-audit Audit live Music search shapes, filters, and continuations continuation [ep] Explore a continuation (ep: 'browse', 'search', or 'next') analyze-file Safely summarize a saved JSON response @@ -3305,7 +3421,8 @@ func showHelp() { -v, --verbose Show raw JSON for browse/action/continuation; expand audits -o, --output Save raw output with owner-only permissions (mode 0600) --body-file Read a sensitive JSON body from a chmod-600 file or stdin - --confirm-live-ai Required acknowledgement for ask-video-live-test + --prompt-file Read a private prompt from a mode-0600 file or stdin + --confirm-live-ai Required acknowledgement for live Ask commands --fresh-chats N Run 1-3 independent summary chats (default: 1) --follow-up Replay the first server-issued follow-up suggestion --authuser N Use Google account at index N (for multi-account) @@ -3329,6 +3446,7 @@ func showHelp() { swift run api-explorer ask-video-audit dQw4w9WgXcQ # Redacted AI audit swift run api-explorer ask-video-parity dQw4w9WgXcQ # Read-only profile matrix swift run api-explorer ask-video-live-test dQw4w9WgXcQ --confirm-live-ai --follow-up + swift run api-explorer ask-video-free-text-test dQw4w9WgXcQ --confirm-live-ai --prompt-file - swift run api-explorer --youtube wire-action get_watch '{"playerRequest":{"videoId":"dQw4w9WgXcQ"},"watchNextRequest":{"videoId":"dQw4w9WgXcQ"}}' swift run api-explorer --youtube wire-action streaming_panel --body-file /path/to/private-body.json @@ -3420,6 +3538,7 @@ func runMain() async { var freshChatCount = 1 var outputFile: String? var bodyFile: String? + var promptFile: String? var filteredArgs: [String] = [] var index = 0 @@ -3458,6 +3577,17 @@ func runMain() async { } index += 1 bodyFile = value + case "--prompt-file": + guard let value = commandLineOptionValue( + after: index, + in: args, + allowSingleDash: true + ) else { + print("❌ --prompt-file requires a path or -") + return + } + index += 1 + promptFile = value case "--authuser": guard let rawValue = commandLineOptionValue(after: index, in: args), let value = Int(rawValue), @@ -3513,6 +3643,10 @@ func runMain() async { showHelp() return } + guard promptFile == nil || command == "ask-video-free-text-test" else { + print("❌ --prompt-file is supported only by ask-video-free-text-test") + return + } switch command { case "browse": @@ -3624,6 +3758,41 @@ func runMain() async { verbose: verbose ) + case "ask-video-free-text-test": + guard filteredArgs.count == 2 else { + print("❌ Usage: ask-video-free-text-test --confirm-live-ai --prompt-file ") + return + } + guard confirmLiveAI else { + print("❌ ask-video-free-text-test requires --confirm-live-ai") + print(" This command submits one private free-text prompt to YouTube.") + return + } + guard let promptFile else { + print("❌ ask-video-free-text-test requires --prompt-file ") + return + } + guard outputFile == nil, + bodyFile == nil, + !verbose, + !clientVersionWasForced, + !forceUnauthenticatedRequests, + !authUserOptionWasSpecified, + globalBrandAccountId == nil, + !includeAskFollowUp, + freshChatCount == 1 + else { + print("❌ ask-video-free-text-test received an unsupported option or account mode") + print(" Supported options: --confirm-live-ai and --prompt-file ") + return + } + do { + let prompt = try loadPrivatePrompt(from: promptFile) + await liveTestAskVideoFreeText(filteredArgs[1], prompt: prompt) + } catch { + print("❌ Could not load the private prompt: \(error.localizedDescription)") + } + case "search-audit": guard filteredArgs.count >= 2 else { print("❌ Usage: search-audit ") diff --git a/Sources/Kaset/Models/YouTube/YouTubeAskModels.swift b/Sources/Kaset/Models/YouTube/YouTubeAskModels.swift index c75751a7c..96e4a6892 100644 --- a/Sources/Kaset/Models/YouTube/YouTubeAskModels.swift +++ b/Sources/Kaset/Models/YouTube/YouTubeAskModels.swift @@ -104,6 +104,7 @@ struct YouTubeAskBootstrap: Sendable { let suggestions: [YouTubeAskSuggestion] private let panelCommand: YouTubeAskOpaqueCommand? + private let freeTextCommand: YouTubeAskOpaqueCommand? private let suggestionStates: [YouTubeAskSuggestionState] private let binding: YouTubeAskBindingState @@ -128,6 +129,7 @@ struct YouTubeAskBootstrap: Sendable { self.videoID = videoID self.suggestions = suggestionStates.map(\.visible) self.panelCommand = parsed.panelCommand + self.freeTextCommand = parsed.freeTextCommand self.suggestionStates = suggestionStates self.binding = YouTubeAskBindingState( videoID: videoID, @@ -143,6 +145,7 @@ struct YouTubeAskBootstrap: Sendable { YouTubeAskConversation( messages: [], suggestionStates: self.suggestionStates, + freeTextCommand: self.freeTextCommand, binding: self.binding ) } @@ -151,6 +154,14 @@ struct YouTubeAskBootstrap: Sendable { self.panelCommand } + fileprivate var freeTextSubmissionCommand: YouTubeAskOpaqueCommand? { + self.freeTextCommand + } + + var hasFreeTextCommand: Bool { + self.freeTextCommand != nil + } + var conversationID: UUID { self.binding.conversationID } @@ -172,18 +183,34 @@ struct YouTubeAskBootstrap: Sendable { self.binding } - static func testing(suggestions: [String]) -> YouTubeAskBootstrap { + static func testing( + suggestions: [String], + allowsFreeText: Bool = false + ) -> YouTubeAskBootstrap { YouTubeAskBootstrap( videoID: "fixture-video", - suggestions: suggestions + suggestions: suggestions, + allowsFreeText: allowsFreeText ) } - private init(videoID: String, suggestions: [String]) { + private init(videoID: String, suggestions: [String], allowsFreeText: Bool) { + let suggestionStates = suggestions.enumerated().map { index, text in + YouTubeAskSuggestionState( + visible: YouTubeAskSuggestion(text: text), + command: YouTubeAskOpaqueCommand("fixture-suggestion-\(index)") + ) + } self.videoID = videoID - self.suggestions = suggestions.map { YouTubeAskSuggestion(text: $0) } + self.suggestions = suggestionStates.map(\.visible) self.panelCommand = nil - self.suggestionStates = [] + self.freeTextCommand = allowsFreeText + ? YouTubeAskOpaqueCommand( + continuation: "fixture-free-text-continuation", + clickTrackingParams: "fixture-click-tracking" + ) + : nil + self.suggestionStates = suggestionStates self.binding = YouTubeAskBindingState( videoID: videoID, authenticationGeneration: 0, @@ -204,16 +231,27 @@ struct YouTubeAskConversation: Sendable { let suggestions: [YouTubeAskSuggestion] private let suggestionStates: [YouTubeAskSuggestionState] + private let freeTextCommand: YouTubeAskOpaqueCommand? private let binding: YouTubeAskBindingState? private let pendingSuggestionID: YouTubeAskSuggestion.ID? + private let pendingFreeTextInput: String? var hasStarted: Bool { self.messages.contains { $0.role == .user } } + var canSubmitFreeText: Bool { + self.binding != nil + && self.freeTextCommand != nil + && !self.hasStarted + && self.pendingSuggestionID == nil + && self.pendingFreeTextInput == nil + } + fileprivate init( messages: [YouTubeAskMessage], suggestionStates: [YouTubeAskSuggestionState], + freeTextCommand: YouTubeAskOpaqueCommand?, binding: YouTubeAskBindingState ) { self.id = binding.conversationID @@ -221,14 +259,17 @@ struct YouTubeAskConversation: Sendable { self.messages = messages self.suggestions = suggestionStates.map(\.visible) self.suggestionStates = suggestionStates + self.freeTextCommand = freeTextCommand self.binding = binding self.pendingSuggestionID = nil + self.pendingFreeTextInput = nil } fileprivate init( previousMessages: [YouTubeAskMessage], parsed: YouTubeAskParsedConversation, - binding: YouTubeAskBindingState + binding: YouTubeAskBindingState, + freeTextCommand: YouTubeAskOpaqueCommand? = nil ) { let suggestionStates = parsed.suggestions.map { parsedSuggestion in YouTubeAskSuggestionState( @@ -242,12 +283,14 @@ struct YouTubeAskConversation: Sendable { self.init( messages: previousMessages + assistantMessages, suggestionStates: suggestionStates, + freeTextCommand: freeTextCommand, binding: binding ) } func appendingUserTurn(for suggestionID: YouTubeAskSuggestion.ID) -> YouTubeAskConversation? { guard self.pendingSuggestionID == nil, + self.pendingFreeTextInput == nil, let selected = self.suggestions.first(where: { $0.id == suggestionID }) else { return nil @@ -258,11 +301,51 @@ struct YouTubeAskConversation: Sendable { messages: self.messages + [YouTubeAskMessage(role: .user, text: selected.text)], suggestions: self.suggestions, suggestionStates: self.suggestionStates, + freeTextCommand: self.freeTextCommand, + binding: self.binding, + pendingSuggestionID: suggestionID, + pendingFreeTextInput: nil + ) + } + + func appendingUserTurn(text: String) -> YouTubeAskConversation? { + let trimmedText = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard self.canSubmitFreeText, + !trimmedText.isEmpty, + trimmedText.count <= YouTubeAskLimits.maximumUserInputCharacters, + trimmedText.utf8.count <= YouTubeAskLimits.maximumUserInputBytes + else { + return nil + } + return YouTubeAskConversation( + id: self.id, + revision: self.revision, + messages: self.messages + [YouTubeAskMessage(role: .user, text: trimmedText)], + suggestions: self.suggestions, + suggestionStates: self.suggestionStates, + freeTextCommand: self.freeTextCommand, binding: self.binding, - pendingSuggestionID: suggestionID + pendingSuggestionID: nil, + pendingFreeTextInput: trimmedText ) } + func pendingFreeTextSubmission( + matching userInputText: String + ) -> (command: YouTubeAskOpaqueCommand, userInputText: String)? { + let trimmedInput = userInputText.trimmingCharacters(in: .whitespacesAndNewlines) + guard let pendingFreeTextInput = self.pendingFreeTextInput, + pendingFreeTextInput == trimmedInput, + let lastMessage = self.messages.last, + case .user = lastMessage.role, + lastMessage.text == pendingFreeTextInput, + let command = self.freeTextCommand + else { + return nil + } + return (command: command, userInputText: pendingFreeTextInput) + } + func command(for suggestionID: YouTubeAskSuggestion.ID) -> YouTubeAskOpaqueCommand? { guard self.pendingSuggestionID == suggestionID else { return nil } return self.suggestionStates.first(where: { $0.visible.id == suggestionID })?.command @@ -298,8 +381,10 @@ struct YouTubeAskConversation: Sendable { messages: self.messages, suggestions: [], suggestionStates: [], + freeTextCommand: nil, binding: nil, - pendingSuggestionID: nil + pendingSuggestionID: nil, + pendingFreeTextInput: nil ) } @@ -309,16 +394,20 @@ struct YouTubeAskConversation: Sendable { messages: [YouTubeAskMessage], suggestions: [YouTubeAskSuggestion], suggestionStates: [YouTubeAskSuggestionState], + freeTextCommand: YouTubeAskOpaqueCommand?, binding: YouTubeAskBindingState?, - pendingSuggestionID: YouTubeAskSuggestion.ID? + pendingSuggestionID: YouTubeAskSuggestion.ID?, + pendingFreeTextInput: String? ) { self.id = id self.revision = revision self.messages = messages self.suggestions = suggestions self.suggestionStates = suggestionStates + self.freeTextCommand = freeTextCommand self.binding = binding self.pendingSuggestionID = pendingSuggestionID + self.pendingFreeTextInput = pendingFreeTextInput } static func testing( @@ -331,8 +420,10 @@ struct YouTubeAskConversation: Sendable { messages: messages, suggestions: suggestions.map { YouTubeAskSuggestion(text: $0) }, suggestionStates: [], + freeTextCommand: nil, binding: nil, - pendingSuggestionID: nil + pendingSuggestionID: nil, + pendingFreeTextInput: nil ) } } @@ -425,7 +516,8 @@ extension YouTubeAskConversation { YouTubeAskConversation( previousMessages: [], parsed: parsed, - binding: bootstrap.bindingState + binding: bootstrap.bindingState, + freeTextCommand: bootstrap.freeTextSubmissionCommand ) } diff --git a/Sources/Kaset/Resources/Localizable.xcstrings b/Sources/Kaset/Resources/Localizable.xcstrings index 090458196..bc870abc1 100644 --- a/Sources/Kaset/Resources/Localizable.xcstrings +++ b/Sources/Kaset/Resources/Localizable.xcstrings @@ -48022,6 +48022,101 @@ } } }, + "Ask about this video...": { + "comment": "Placeholder for the free-text input in the YouTube Ask Gemini panel", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "اسأل عن هذا الفيديو..." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Frag etwas zu diesem Video…" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Ask about this video..." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Pregunta sobre este vídeo…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Posez une question sur cette vidéo..." + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Tanyakan tentang video ini..." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Chiedi informazioni su questo video…" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 동영상에 대해 질문하세요..." + } + }, + "nl": { + "stringUnit": { + "state": "translated", + "value": "Vraag iets over deze video…" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zapytaj o ten film…" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Pergunte sobre este vídeo…" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Спросите об этом видео…" + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Fråga om den här videon…" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bu video hakkında sorun..." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Запитайте про це відео..." + } + } + } + }, "Responses are generated by YouTube and may be inaccurate.": { "comment": "Disclosure shown in the YouTube Ask Gemini panel", "localizations": { @@ -48683,6 +48778,101 @@ } } }, + "Send": { + "comment": "Accessibility label for the free-text send button in YouTube Ask Gemini", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إرسال" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Senden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Send" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Enviar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Envoyer" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Kirim" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Invia" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "보내기" + } + }, + "nl": { + "stringUnit": { + "state": "translated", + "value": "Versturen" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wyślij" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Enviar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отправить" + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Skicka" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Gönder" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Надіслати" + } + } + } + }, "Sending…": { "localizations": { "ar": { diff --git a/Sources/Kaset/Resources/ar.lproj/Localizable.strings b/Sources/Kaset/Resources/ar.lproj/Localizable.strings index 19cfab385..ad89dfd87 100644 --- a/Sources/Kaset/Resources/ar.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/ar.lproj/Localizable.strings @@ -546,6 +546,7 @@ // YouTube Ask Gemini "Ask Gemini" = "اسأل Gemini"; +"Ask about this video..." = "اسأل عن هذا الفيديو..."; "Responses are generated by YouTube and may be inaccurate." = "يتم إنشاء الردود بواسطة YouTube وقد تكون غير دقيقة."; "Collapse Ask Gemini" = "طي «اسأل Gemini»"; "Expand Ask Gemini" = "توسيع «اسأل Gemini»"; @@ -553,6 +554,7 @@ "You asked: %@" = "لقد سألت: %@"; "YouTube response: %@" = "رد YouTube: %@"; "Preparing Ask Gemini…" = "جارٍ إعداد «اسأل Gemini»…"; +"Send" = "إرسال"; "Sending…" = "جارٍ الإرسال…"; "Sign in again to use Ask Gemini." = "سجّل الدخول مرة أخرى لاستخدام «اسأل Gemini»."; "Ask Gemini is temporarily rate limited. Try again later." = "تم تقييد معدل استخدام «اسأل Gemini» مؤقتًا. حاول مرة أخرى لاحقًا."; diff --git a/Sources/Kaset/Resources/de.lproj/Localizable.strings b/Sources/Kaset/Resources/de.lproj/Localizable.strings index 8b92a0b89..32a29445d 100644 --- a/Sources/Kaset/Resources/de.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/de.lproj/Localizable.strings @@ -546,6 +546,7 @@ // YouTube Ask Gemini "Ask Gemini" = "Gemini fragen"; +"Ask about this video..." = "Frag etwas zu diesem Video…"; "Responses are generated by YouTube and may be inaccurate." = "Die Antworten werden von YouTube generiert und können ungenau sein."; "Collapse Ask Gemini" = "„Gemini fragen“ einklappen"; "Expand Ask Gemini" = "„Gemini fragen“ ausklappen"; @@ -553,6 +554,7 @@ "You asked: %@" = "Du hast gefragt: %@"; "YouTube response: %@" = "YouTube-Antwort: %@"; "Preparing Ask Gemini…" = "„Gemini fragen“ wird vorbereitet…"; +"Send" = "Senden"; "Sending…" = "Wird gesendet…"; "Sign in again to use Ask Gemini." = "Melde dich erneut an, um „Gemini fragen“ zu verwenden."; "Ask Gemini is temporarily rate limited. Try again later." = "„Gemini fragen“ ist vorübergehend aufgrund zu vieler Anfragen eingeschränkt. Versuche es später erneut."; diff --git a/Sources/Kaset/Resources/en.lproj/Localizable.strings b/Sources/Kaset/Resources/en.lproj/Localizable.strings index 2e666d57c..f26611a92 100644 --- a/Sources/Kaset/Resources/en.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/en.lproj/Localizable.strings @@ -17,6 +17,7 @@ // YouTube Ask Gemini "Ask Gemini" = "Ask Gemini"; +"Ask about this video..." = "Ask about this video..."; "Responses are generated by YouTube and may be inaccurate." = "Responses are generated by YouTube and may be inaccurate."; "Collapse Ask Gemini" = "Collapse Ask Gemini"; "Expand Ask Gemini" = "Expand Ask Gemini"; @@ -24,6 +25,7 @@ "You asked: %@" = "You asked: %@"; "YouTube response: %@" = "YouTube response: %@"; "Preparing Ask Gemini…" = "Preparing Ask Gemini…"; +"Send" = "Send"; "Sending…" = "Sending…"; "Sign in again to use Ask Gemini." = "Sign in again to use Ask Gemini."; "Ask Gemini is temporarily rate limited. Try again later." = "Ask Gemini is temporarily rate limited. Try again later."; diff --git a/Sources/Kaset/Resources/es.lproj/Localizable.strings b/Sources/Kaset/Resources/es.lproj/Localizable.strings index 2dff09961..6c04e6943 100644 --- a/Sources/Kaset/Resources/es.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/es.lproj/Localizable.strings @@ -546,6 +546,7 @@ // YouTube Ask Gemini "Ask Gemini" = "Preguntar a Gemini"; +"Ask about this video..." = "Pregunta sobre este vídeo…"; "Responses are generated by YouTube and may be inaccurate." = "Las respuestas las genera YouTube y pueden contener errores."; "Collapse Ask Gemini" = "Contraer «Preguntar a Gemini»"; "Expand Ask Gemini" = "Expandir «Preguntar a Gemini»"; @@ -553,6 +554,7 @@ "You asked: %@" = "Has preguntado: %@"; "YouTube response: %@" = "Respuesta de YouTube: %@"; "Preparing Ask Gemini…" = "Preparando Preguntar a Gemini…"; +"Send" = "Enviar"; "Sending…" = "Enviando…"; "Sign in again to use Ask Gemini." = "Vuelve a iniciar sesión para usar Preguntar a Gemini."; "Ask Gemini is temporarily rate limited. Try again later." = "Preguntar a Gemini está limitado temporalmente. Inténtalo de nuevo más tarde."; diff --git a/Sources/Kaset/Resources/fr.lproj/Localizable.strings b/Sources/Kaset/Resources/fr.lproj/Localizable.strings index 1c3c6f455..4c487a00a 100644 --- a/Sources/Kaset/Resources/fr.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/fr.lproj/Localizable.strings @@ -546,6 +546,7 @@ // YouTube Ask Gemini "Ask Gemini" = "Demander à Gemini"; +"Ask about this video..." = "Posez une question sur cette vidéo..."; "Responses are generated by YouTube and may be inaccurate." = "Les réponses sont générées par YouTube et peuvent être inexactes."; "Collapse Ask Gemini" = "Replier « Demander à Gemini »"; "Expand Ask Gemini" = "Déplier « Demander à Gemini »"; @@ -553,6 +554,7 @@ "You asked: %@" = "Vous avez demandé: %@"; "YouTube response: %@" = "Réponse de YouTube: %@"; "Preparing Ask Gemini…" = "Préparation de Demander à Gemini…"; +"Send" = "Envoyer"; "Sending…" = "Envoi…"; "Sign in again to use Ask Gemini." = "Reconnectez-vous pour utiliser Demander à Gemini."; "Ask Gemini is temporarily rate limited. Try again later." = "Demander à Gemini est temporairement limité. Réessayez plus tard."; diff --git a/Sources/Kaset/Resources/id.lproj/Localizable.strings b/Sources/Kaset/Resources/id.lproj/Localizable.strings index ef2c188af..a2e036e50 100644 --- a/Sources/Kaset/Resources/id.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/id.lproj/Localizable.strings @@ -546,6 +546,7 @@ // YouTube Ask Gemini "Ask Gemini" = "Tanya Gemini"; +"Ask about this video..." = "Tanyakan tentang video ini..."; "Responses are generated by YouTube and may be inaccurate." = "Respons dibuat oleh YouTube dan mungkin tidak akurat."; "Collapse Ask Gemini" = "Ciutkan Tanya Gemini"; "Expand Ask Gemini" = "Luaskan Tanya Gemini"; @@ -553,6 +554,7 @@ "You asked: %@" = "Anda bertanya: %@"; "YouTube response: %@" = "Respons YouTube: %@"; "Preparing Ask Gemini…" = "Menyiapkan Tanya Gemini…"; +"Send" = "Kirim"; "Sending…" = "Mengirim…"; "Sign in again to use Ask Gemini." = "Masuk lagi untuk menggunakan Tanya Gemini."; "Ask Gemini is temporarily rate limited. Try again later." = "Tanya Gemini sedang dibatasi sementara. Coba lagi nanti."; diff --git a/Sources/Kaset/Resources/it.lproj/Localizable.strings b/Sources/Kaset/Resources/it.lproj/Localizable.strings index b81ea5ee0..8a80e6807 100644 --- a/Sources/Kaset/Resources/it.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/it.lproj/Localizable.strings @@ -546,6 +546,7 @@ // YouTube Ask Gemini "Ask Gemini" = "Chiedi a Gemini"; +"Ask about this video..." = "Chiedi informazioni su questo video…"; "Responses are generated by YouTube and may be inaccurate." = "Le risposte sono generate da YouTube e potrebbero essere inesatte."; "Collapse Ask Gemini" = "Comprimi «Chiedi a Gemini»"; "Expand Ask Gemini" = "Espandi «Chiedi a Gemini»"; @@ -553,6 +554,7 @@ "You asked: %@" = "Hai chiesto: %@"; "YouTube response: %@" = "Risposta di YouTube: %@"; "Preparing Ask Gemini…" = "Preparazione di Chiedi a Gemini…"; +"Send" = "Invia"; "Sending…" = "Invio…"; "Sign in again to use Ask Gemini." = "Accedi di nuovo per usare Chiedi a Gemini."; "Ask Gemini is temporarily rate limited. Try again later." = "Chiedi a Gemini è temporaneamente limitato. Riprova più tardi."; diff --git a/Sources/Kaset/Resources/ko.lproj/Localizable.strings b/Sources/Kaset/Resources/ko.lproj/Localizable.strings index 1f8910097..4a87888bf 100644 --- a/Sources/Kaset/Resources/ko.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/ko.lproj/Localizable.strings @@ -546,6 +546,7 @@ // YouTube Ask Gemini "Ask Gemini" = "Gemini에게 질문"; +"Ask about this video..." = "이 동영상에 대해 질문하세요..."; "Responses are generated by YouTube and may be inaccurate." = "응답은 YouTube에서 생성되며 정확하지 않을 수 있습니다."; "Collapse Ask Gemini" = "Gemini에게 질문 접기"; "Expand Ask Gemini" = "Gemini에게 질문 펼치기"; @@ -553,6 +554,7 @@ "You asked: %@" = "질문한 내용: %@"; "YouTube response: %@" = "YouTube 응답: %@"; "Preparing Ask Gemini…" = "Gemini에게 질문 준비 중…"; +"Send" = "보내기"; "Sending…" = "전송 중…"; "Sign in again to use Ask Gemini." = "Gemini에게 질문을 사용하려면 다시 로그인하세요."; "Ask Gemini is temporarily rate limited. Try again later." = "Gemini에게 질문의 요청이 일시적으로 제한되었습니다. 나중에 다시 시도하세요."; diff --git a/Sources/Kaset/Resources/nl.lproj/Localizable.strings b/Sources/Kaset/Resources/nl.lproj/Localizable.strings index e53f03f47..1c65491fa 100644 --- a/Sources/Kaset/Resources/nl.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/nl.lproj/Localizable.strings @@ -546,6 +546,7 @@ // YouTube Ask Gemini "Ask Gemini" = "Vraag Gemini"; +"Ask about this video..." = "Vraag iets over deze video…"; "Responses are generated by YouTube and may be inaccurate." = "Antwoorden worden gegenereerd door YouTube en kunnen onnauwkeurig zijn."; "Collapse Ask Gemini" = "Vraag Gemini inklappen"; "Expand Ask Gemini" = "Vraag Gemini uitklappen"; @@ -553,6 +554,7 @@ "You asked: %@" = "Je vroeg: %@"; "YouTube response: %@" = "Antwoord van YouTube: %@"; "Preparing Ask Gemini…" = "Vraag Gemini voorbereiden…"; +"Send" = "Versturen"; "Sending…" = "Versturen…"; "Sign in again to use Ask Gemini." = "Meld je opnieuw aan om Vraag Gemini te gebruiken."; "Ask Gemini is temporarily rate limited. Try again later." = "Vraag Gemini is tijdelijk beperkt. Probeer het later opnieuw."; diff --git a/Sources/Kaset/Resources/pl.lproj/Localizable.strings b/Sources/Kaset/Resources/pl.lproj/Localizable.strings index 956f80523..f4ce695f4 100644 --- a/Sources/Kaset/Resources/pl.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/pl.lproj/Localizable.strings @@ -546,6 +546,7 @@ // YouTube Ask Gemini "Ask Gemini" = "Zapytaj Gemini"; +"Ask about this video..." = "Zapytaj o ten film…"; "Responses are generated by YouTube and may be inaccurate." = "Odpowiedzi są generowane przez YouTube i mogą być niedokładne."; "Collapse Ask Gemini" = "Zwiń Zapytaj Gemini"; "Expand Ask Gemini" = "Rozwiń Zapytaj Gemini"; @@ -553,6 +554,7 @@ "You asked: %@" = "Twoje pytanie: %@"; "YouTube response: %@" = "Odpowiedź YouTube: %@"; "Preparing Ask Gemini…" = "Przygotowywanie Zapytaj Gemini…"; +"Send" = "Wyślij"; "Sending…" = "Wysyłanie…"; "Sign in again to use Ask Gemini." = "Zaloguj się ponownie, aby używać Zapytaj Gemini."; "Ask Gemini is temporarily rate limited. Try again later." = "Zapytaj Gemini jest tymczasowo objęte limitem żądań. Spróbuj ponownie później."; diff --git a/Sources/Kaset/Resources/pt.lproj/Localizable.strings b/Sources/Kaset/Resources/pt.lproj/Localizable.strings index 1bea364a2..dbc0ce2e7 100644 --- a/Sources/Kaset/Resources/pt.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/pt.lproj/Localizable.strings @@ -546,6 +546,7 @@ // YouTube Ask Gemini "Ask Gemini" = "Perguntar ao Gemini"; +"Ask about this video..." = "Pergunte sobre este vídeo…"; "Responses are generated by YouTube and may be inaccurate." = "As respostas são geradas pelo YouTube e podem estar incorretas."; "Collapse Ask Gemini" = "Recolher Perguntar ao Gemini"; "Expand Ask Gemini" = "Expandir Perguntar ao Gemini"; @@ -553,6 +554,7 @@ "You asked: %@" = "Você perguntou: %@"; "YouTube response: %@" = "Resposta do YouTube: %@"; "Preparing Ask Gemini…" = "Preparando Perguntar ao Gemini…"; +"Send" = "Enviar"; "Sending…" = "Enviando…"; "Sign in again to use Ask Gemini." = "Entre novamente para usar Perguntar ao Gemini."; "Ask Gemini is temporarily rate limited. Try again later." = "Perguntar ao Gemini está temporariamente limitado. Tente novamente mais tarde."; diff --git a/Sources/Kaset/Resources/ru.lproj/Localizable.strings b/Sources/Kaset/Resources/ru.lproj/Localizable.strings index 71ba40413..b940b0e5d 100644 --- a/Sources/Kaset/Resources/ru.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/ru.lproj/Localizable.strings @@ -546,6 +546,7 @@ // YouTube Ask Gemini "Ask Gemini" = "Спросить Gemini"; +"Ask about this video..." = "Спросите об этом видео…"; "Responses are generated by YouTube and may be inaccurate." = "Ответы создаются YouTube и могут быть неточными."; "Collapse Ask Gemini" = "Свернуть «Спросить Gemini»"; "Expand Ask Gemini" = "Развернуть «Спросить Gemini»"; @@ -553,6 +554,7 @@ "You asked: %@" = "Ваш вопрос: %@"; "YouTube response: %@" = "Ответ YouTube: %@"; "Preparing Ask Gemini…" = "Подготовка «Спросить Gemini»…"; +"Send" = "Отправить"; "Sending…" = "Отправка…"; "Sign in again to use Ask Gemini." = "Войдите снова, чтобы использовать «Спросить Gemini»."; "Ask Gemini is temporarily rate limited. Try again later." = "Для функции «Спросить Gemini» временно действует ограничение частоты запросов. Повторите попытку позже."; diff --git a/Sources/Kaset/Resources/sv.lproj/Localizable.strings b/Sources/Kaset/Resources/sv.lproj/Localizable.strings index 91d807de1..2586ef8d6 100644 --- a/Sources/Kaset/Resources/sv.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/sv.lproj/Localizable.strings @@ -546,6 +546,7 @@ // YouTube Ask Gemini "Ask Gemini" = "Fråga Gemini"; +"Ask about this video..." = "Fråga om den här videon…"; "Responses are generated by YouTube and may be inaccurate." = "Svaren genereras av YouTube och kan vara felaktiga."; "Collapse Ask Gemini" = "Fäll ihop Fråga Gemini"; "Expand Ask Gemini" = "Fäll ut Fråga Gemini"; @@ -553,6 +554,7 @@ "You asked: %@" = "Du frågade: %@"; "YouTube response: %@" = "Svar från YouTube: %@"; "Preparing Ask Gemini…" = "Förbereder Fråga Gemini…"; +"Send" = "Skicka"; "Sending…" = "Skickar…"; "Sign in again to use Ask Gemini." = "Logga in igen för att använda Fråga Gemini."; "Ask Gemini is temporarily rate limited. Try again later." = "Fråga Gemini är tillfälligt begränsad. Försök igen senare."; diff --git a/Sources/Kaset/Resources/tr.lproj/Localizable.strings b/Sources/Kaset/Resources/tr.lproj/Localizable.strings index 44d226d43..8ec93a4af 100644 --- a/Sources/Kaset/Resources/tr.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/tr.lproj/Localizable.strings @@ -546,6 +546,7 @@ // YouTube Ask Gemini "Ask Gemini" = "Gemini'ye Sor"; +"Ask about this video..." = "Bu video hakkında sorun..."; "Responses are generated by YouTube and may be inaccurate." = "Yanıtlar YouTube tarafından oluşturulur ve hatalı olabilir."; "Collapse Ask Gemini" = "Gemini'ye Sor bölümünü daralt"; "Expand Ask Gemini" = "Gemini'ye Sor bölümünü genişlet"; @@ -553,6 +554,7 @@ "You asked: %@" = "Siz sordunuz: %@"; "YouTube response: %@" = "YouTube yanıtı: %@"; "Preparing Ask Gemini…" = "Gemini'ye Sor hazırlanıyor…"; +"Send" = "Gönder"; "Sending…" = "Gönderiliyor…"; "Sign in again to use Ask Gemini." = "Gemini'ye Sor'u kullanmak için yeniden giriş yapın."; "Ask Gemini is temporarily rate limited. Try again later." = "Gemini'ye Sor için istek hızı geçici olarak sınırlandı. Daha sonra tekrar deneyin."; diff --git a/Sources/Kaset/Resources/uk.lproj/Localizable.strings b/Sources/Kaset/Resources/uk.lproj/Localizable.strings index c08426641..b84a779eb 100644 --- a/Sources/Kaset/Resources/uk.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/uk.lproj/Localizable.strings @@ -546,6 +546,7 @@ // YouTube Ask Gemini "Ask Gemini" = "Запитати Gemini"; +"Ask about this video..." = "Запитайте про це відео..."; "Responses are generated by YouTube and may be inaccurate." = "Відповіді створює YouTube, і вони можуть бути неточними."; "Collapse Ask Gemini" = "Згорнути «Запитати Gemini»"; "Expand Ask Gemini" = "Розгорнути «Запитати Gemini»"; @@ -553,6 +554,7 @@ "You asked: %@" = "Ваше запитання: %@"; "YouTube response: %@" = "Відповідь YouTube: %@"; "Preparing Ask Gemini…" = "Підготовка «Запитати Gemini»…"; +"Send" = "Надіслати"; "Sending…" = "Надсилання…"; "Sign in again to use Ask Gemini." = "Увійдіть знову, щоб використовувати «Запитати Gemini»."; "Ask Gemini is temporarily rate limited. Try again later." = "Для «Запитати Gemini» тимчасово діє обмеження частоти запитів. Спробуйте пізніше."; diff --git a/Sources/Kaset/Services/API/MockUITestYouTubeClient.swift b/Sources/Kaset/Services/API/MockUITestYouTubeClient.swift index 59da9587c..2fb7777c1 100644 --- a/Sources/Kaset/Services/API/MockUITestYouTubeClient.swift +++ b/Sources/Kaset/Services/API/MockUITestYouTubeClient.swift @@ -108,10 +108,13 @@ final class MockUITestYouTubeClient: YouTubeClientProtocol { try await YouTubeWatchPage( data: self.getWatchNext(videoId: videoId), askBootstrap: self.isAskGeminiEligible - ? YouTubeAskBootstrap.testing(suggestions: [ - "Explain the main idea", - "List the key moments", - ]) + ? YouTubeAskBootstrap.testing( + suggestions: [ + "Explain the main idea", + "List the key moments", + ], + allowsFreeText: true + ) : nil ) } @@ -119,9 +122,7 @@ final class MockUITestYouTubeClient: YouTubeClientProtocol { func loadAskConversation( from bootstrap: YouTubeAskBootstrap ) async throws -> YouTubeAskConversation { - YouTubeAskConversation.testing( - suggestions: bootstrap.suggestions.map(\.text) - ) + YouTubeAskConversation.direct(from: bootstrap) } func continueAskConversation( @@ -139,6 +140,22 @@ final class MockUITestYouTubeClient: YouTubeClientProtocol { ) } + func continueAskConversation( + _ conversation: YouTubeAskConversation, + submitting _: String, + playerOffsetMilliseconds _: Int64 + ) async throws -> YouTubeAskConversation { + YouTubeAskConversation.testing( + messages: conversation.messages + [ + YouTubeAskMessage( + role: .assistant, + text: "This is a synthetic free-text response for UI tests." + ), + ], + suggestions: ["Show another detail"] + ) + } + func getComments(continuation _: String) async throws -> YouTubeCommentsPage { YouTubeCommentsPage( comments: [ diff --git a/Sources/Kaset/Services/API/YouTubeClient+Ask.swift b/Sources/Kaset/Services/API/YouTubeClient+Ask.swift index 48f5d8366..76569c6b5 100644 --- a/Sources/Kaset/Services/API/YouTubeClient+Ask.swift +++ b/Sources/Kaset/Services/API/YouTubeClient+Ask.swift @@ -30,7 +30,9 @@ extension YouTubeClient { bodyData: bodyData, snapshot: snapshot ) - guard !parsed.suggestions.isEmpty else { + guard !parsed.suggestions.isEmpty + || bootstrap.hasFreeTextCommand + else { throw YouTubeAskClientError.invalidResponse } return YouTubeAskConversation.materialized( @@ -87,17 +89,90 @@ extension YouTubeClient { return nextConversation } + func continueAskConversation( + _ conversation: YouTubeAskConversation, + submitting userInputText: String, + playerOffsetMilliseconds: Int64 + ) async throws -> YouTubeAskConversation { + guard let videoID = conversation.boundVideoID else { + throw CancellationError() + } + let snapshot = try await self.makeAskRequestSnapshot(videoID: videoID) + guard conversation.isBound( + toVideoID: snapshot.videoID, + authenticationGeneration: snapshot.authenticationGeneration, + accountBinding: snapshot.accountBinding, + clientGeneration: snapshot.clientGeneration + ), let submission = conversation.pendingFreeTextSubmission(matching: userInputText) + else { + throw CancellationError() + } + + let bodyData: Data + let clickTrackingContextData: Data + let request: URLRequest + do { + bodyData = try YouTubeAskRequestBuilder.makeFreeTextBody( + command: submission.command, + clientMessageID: self.nextAskClientMessageID(), + userInputText: submission.userInputText, + playerOffsetMilliseconds: playerOffsetMilliseconds + ) + clickTrackingContextData = try YouTubeAskRequestBuilder.makeFreeTextClickTrackingContext( + command: submission.command + ) + request = try self.makeAskRequest( + endpoint: "get_panel", + bodyData: bodyData, + snapshot: snapshot, + clickTrackingContextData: clickTrackingContextData + ) + guard let finalBody = request.httpBody else { + throw YouTubeAskClientError.invalidResponse + } + try YouTubeAskRequestBuilder.validateRequestBodySize(finalBody) + } catch { + throw YouTubeAskClientError.invalidResponse + } + guard self.consumeAskRevision( + conversationID: conversation.id, + revision: conversation.revision + ) else { + throw CancellationError() + } + + let parsed = try await self.performAskPanelRequest(request: request, snapshot: snapshot) + guard !parsed.messages.isEmpty, + let nextConversation = YouTubeAskConversation.continued( + from: conversation, + parsed: parsed + ) + else { + throw YouTubeAskClientError.invalidResponse + } + return nextConversation + } + private func performAskPanelRequest( bodyData: Data, - snapshot: AskRequestSnapshot + snapshot: AskRequestSnapshot, + clickTrackingContextData: Data? = nil ) async throws -> YouTubeAskParsedConversation { try self.validateAskRequestSnapshot(snapshot) let request = try self.makeAskRequest( endpoint: "get_panel", bodyData: bodyData, - snapshot: snapshot + snapshot: snapshot, + clickTrackingContextData: clickTrackingContextData ) + return try await self.performAskPanelRequest(request: request, snapshot: snapshot) + } + private func performAskPanelRequest( + request: URLRequest, + snapshot: AskRequestSnapshot + ) async throws -> YouTubeAskParsedConversation { + try self.validateAskRequestSnapshot(snapshot) let response: YouTubeAskHTTPResponse do { response = try await self.askTransport.send(request) diff --git a/Sources/Kaset/Services/API/YouTubeClient.swift b/Sources/Kaset/Services/API/YouTubeClient.swift index 7604cf14c..0d1764c13 100644 --- a/Sources/Kaset/Services/API/YouTubeClient.swift +++ b/Sources/Kaset/Services/API/YouTubeClient.swift @@ -1067,14 +1067,26 @@ final class YouTubeClient: YouTubeClientProtocol { // swiftlint:disable:this typ func makeAskRequest( endpoint: String, bodyData: Data, - snapshot: AskRequestSnapshot + snapshot: AskRequestSnapshot, + clickTrackingContextData: Data? = nil ) throws -> URLRequest { guard endpoint == "get_panel", var body = try JSONSerialization.jsonObject(with: bodyData) as? [String: Any] else { throw YouTubeAskClientError.invalidResponse } - body["context"] = snapshot.context + var context = snapshot.context + if let clickTrackingContextData { + guard let clickTrackingContext = try JSONSerialization.jsonObject( + with: clickTrackingContextData + ) as? [String: Any], + let clickTracking = clickTrackingContext["clickTracking"] as? [String: Any] + else { + throw YouTubeAskClientError.invalidResponse + } + context["clickTracking"] = clickTracking + } + body["context"] = context var components = URLComponents(string: "\(Self.baseURL)/\(endpoint)") components?.queryItems = [ diff --git a/Sources/Kaset/Services/YouTubeProtocols.swift b/Sources/Kaset/Services/YouTubeProtocols.swift index 54c832e72..7a869629c 100644 --- a/Sources/Kaset/Services/YouTubeProtocols.swift +++ b/Sources/Kaset/Services/YouTubeProtocols.swift @@ -73,6 +73,14 @@ protocol YouTubeClientProtocol: Sendable { selecting suggestionID: YouTubeAskSuggestion.ID ) async throws -> YouTubeAskConversation + /// Submits one validated free-text prompt using the current watch-scoped + /// server command. Free text is one-shot until New Chat in v1. + func continueAskConversation( + _ conversation: YouTubeAskConversation, + submitting userInputText: String, + playerOffsetMilliseconds: Int64 + ) async throws -> YouTubeAskConversation + /// Fetches a page of comments by continuation token. func getComments(continuation: String) async throws -> YouTubeCommentsPage diff --git a/Sources/Kaset/Utilities/MainWindowLayout.swift b/Sources/Kaset/Utilities/MainWindowLayout.swift index ad7494dd8..710d63817 100644 --- a/Sources/Kaset/Utilities/MainWindowLayout.swift +++ b/Sources/Kaset/Utilities/MainWindowLayout.swift @@ -15,6 +15,8 @@ enum MainWindowLayout { static let minimumHeight: CGFloat = 600 static let defaultWidth: CGFloat = 1100 static let defaultHeight: CGFloat = 760 + /// Shared top inset for the Music command bar and YouTube Ask panel. + static let aiTaskSurfaceTopPadding: CGFloat = 72 static var minimumContentSize: NSSize { NSSize(width: minimumWidth, height: minimumHeight) diff --git a/Sources/Kaset/ViewModels/YouTube/YouTubeAskViewModel.swift b/Sources/Kaset/ViewModels/YouTube/YouTubeAskViewModel.swift index 9797c3596..b2be1f224 100644 --- a/Sources/Kaset/ViewModels/YouTube/YouTubeAskViewModel.swift +++ b/Sources/Kaset/ViewModels/YouTube/YouTubeAskViewModel.swift @@ -1,5 +1,6 @@ import Foundation import Observation +import YouTubeAskCore /// Owns one watch-scoped Ask Gemini conversation and every task that can mutate it. @MainActor @@ -27,6 +28,7 @@ final class YouTubeAskViewModel { private(set) var requiresNewChat = false private(set) var accessibilityAnnouncement: AccessibilityAnnouncement? private(set) var accessibilityAnnouncementSequence = 0 + var inputText = "" private var bootstrap: YouTubeAskBootstrap? private var operationGeneration: UInt64 = 0 @@ -61,6 +63,20 @@ final class YouTubeAskViewModel { self.isAvailable && (self.hasStarted || self.requiresNewChat) } + var acceptsFreeTextInput: Bool { + !self.isBusy + && !self.requiresNewChat + && self.conversation?.canSubmitFreeText == true + } + + var canSubmitInput: Bool { + let trimmedInput = self.inputText.trimmingCharacters(in: .whitespacesAndNewlines) + return self.acceptsFreeTextInput + && !trimmedInput.isEmpty + && trimmedInput.count <= YouTubeAskLimits.maximumUserInputCharacters + && trimmedInput.utf8.count <= YouTubeAskLimits.maximumUserInputBytes + } + /// Replaces all prior state with a fresh watch-page bootstrap. The toolbar /// action remains hidden until eligibility is known, and presenting the panel /// materializes it lazily. @@ -74,6 +90,7 @@ final class YouTubeAskViewModel { self.presentationError = nil self.requiresNewChat = false self.accessibilityAnnouncement = nil + self.inputText = "" } func toggleExpanded() { @@ -98,6 +115,7 @@ final class YouTubeAskViewModel { } self.presentationError = nil + self.inputText = "" self.conversation = pendingConversation let generation = self.beginOperation(.sending) let client = self.client @@ -112,6 +130,58 @@ final class YouTubeAskViewModel { self.conversation = nextConversation self.requiresNewChat = false self.presentationError = nil + self.inputText = "" + self.finishOperation(generation: generation) + self.publishAnnouncement(.responseReady) + } catch is CancellationError { + guard let self, self.operationGeneration == generation else { return } + if Task.isCancelled { + self.finishOperation(generation: generation) + return + } + self.failSubmission( + pendingConversation: pendingConversation, + error: YouTubeAskClientError.sessionChanged, + generation: generation + ) + } catch { + guard let self, self.operationGeneration == generation else { return } + self.failSubmission( + pendingConversation: pendingConversation, + error: error, + generation: generation + ) + } + } + } + + func submitInput(playerOffsetMilliseconds: Int64) { + guard !self.isBusy, + !self.requiresNewChat, + let conversation = self.conversation, + let pendingConversation = conversation.appendingUserTurn(text: self.inputText), + let submittedText = pendingConversation.messages.last?.text + else { + return + } + + self.presentationError = nil + self.inputText = "" + self.conversation = pendingConversation + let generation = self.beginOperation(.sending) + let client = self.client + + self.requestTask = Task { @MainActor [weak self, client, pendingConversation] in + do { + let nextConversation = try await client.continueAskConversation( + pendingConversation, + submitting: submittedText, + playerOffsetMilliseconds: playerOffsetMilliseconds + ) + guard let self, self.operationGeneration == generation else { return } + self.conversation = nextConversation + self.requiresNewChat = false + self.presentationError = nil self.finishOperation(generation: generation) self.publishAnnouncement(.responseReady) } catch is CancellationError { @@ -160,6 +230,7 @@ final class YouTubeAskViewModel { self.isAvailable = true self.requiresNewChat = false self.presentationError = nil + self.inputText = "" self.finishOperation(generation: generation) self.publishAnnouncement(.newChatReady) } catch is CancellationError { @@ -191,6 +262,7 @@ final class YouTubeAskViewModel { self.presentationError = nil self.requiresNewChat = false self.accessibilityAnnouncement = nil + self.inputText = "" } private func prepareInitialConversationIfNeeded() { diff --git a/Sources/Kaset/Views/MainWindow.swift b/Sources/Kaset/Views/MainWindow.swift index 9c92256cd..8565692e7 100644 --- a/Sources/Kaset/Views/MainWindow.swift +++ b/Sources/Kaset/Views/MainWindow.swift @@ -15,10 +15,6 @@ struct MainWindow: View { // swiftlint:disable:this type_body_length } } - private enum Layout { - static let commandBarTopPadding: CGFloat = 72 - } - @Environment(AuthService.self) private var authService @Environment(PlayerService.self) private var playerService @Environment(YouTubePlayerService.self) private var youtubePlayerService @@ -197,7 +193,7 @@ struct MainWindow: View { // swiftlint:disable:this type_body_length Spacer(minLength: 0) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .padding(.top, Self.Layout.commandBarTopPadding) + .padding(.top, MainWindowLayout.aiTaskSurfaceTopPadding) } .animation(.easeInOut(duration: 0.15), value: self.isCommandBarPresented) } diff --git a/Sources/Kaset/Views/YouTube/YouTubeAskPanelView.swift b/Sources/Kaset/Views/YouTube/YouTubeAskPanelView.swift index ee4a69e18..acd999a91 100644 --- a/Sources/Kaset/Views/YouTube/YouTubeAskPanelView.swift +++ b/Sources/Kaset/Views/YouTube/YouTubeAskPanelView.swift @@ -27,7 +27,10 @@ struct YouTubeAskToolbarButton: View { // MARK: - YouTubeAskFloatingOverlay struct YouTubeAskFloatingOverlay: View { + private static let bottomPadding: CGFloat = 16 + let viewModel: YouTubeAskViewModel + let playerOffsetMilliseconds: Int64 var body: some View { if self.viewModel.isAvailable, self.viewModel.isExpanded { @@ -45,14 +48,20 @@ struct YouTubeAskFloatingOverlay: View { VStack(spacing: 0) { YouTubeAskPanelView( viewModel: self.viewModel, - maximumHeight: max(0, geometry.size.height - 32) + maximumHeight: max( + 0, + geometry.size.height + - MainWindowLayout.aiTaskSurfaceTopPadding + - Self.bottomPadding + ), + playerOffsetMilliseconds: self.playerOffsetMilliseconds ) .transition(.opacity.combined(with: .scale(scale: 0.95))) Spacer(minLength: 0) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .padding(.top, 16) + .padding(.top, MainWindowLayout.aiTaskSurfaceTopPadding) YouTubeAskEscapeKeyMonitor { self.viewModel.setExpanded(false) @@ -144,88 +153,221 @@ private final class YouTubeAskEscapeMonitorView: NSView { // MARK: - YouTubeAskPanelView -/// Floating, watch-scoped Ask Gemini panel. It only presents server-issued -/// suggestions; free-form input is intentionally not part of this surface. +/// Floating, watch-scoped Ask Gemini panel. It presents YouTube-generated +/// messages and server-issued suggestions in the same compact shell as the +/// music command bar. struct YouTubeAskPanelView: View { + private enum FocusTarget: Hashable { + case input + case close + case suggestion(YouTubeAskSuggestion.ID) + case newChat + } + + private enum ScrollTarget: Hashable { + case message(UUID) + case status + case suggestion(YouTubeAskSuggestion.ID) + case newChat + } + let viewModel: YouTubeAskViewModel let maximumHeight: CGFloat + let playerOffsetMilliseconds: Int64 @Namespace private var askPanelNamespace - @FocusState private var isHeaderFocused: Bool + @FocusState private var focusedControl: FocusTarget? var body: some View { + @Bindable var viewModel = self.viewModel + CompatGlassContainer(spacing: 0) { - VStack(alignment: .leading, spacing: 12) { - self.header + VStack(spacing: 0) { + Group { + if self.viewModel.acceptsFreeTextInput { + self.inputRow(text: $viewModel.inputText) + } else { + self.headerRow + } + } + .padding(.horizontal, 16) + .padding(.vertical, 14) - Text( - "Responses are generated by YouTube and may be inaccurate.", - comment: "Disclosure shown in the YouTube Ask Gemini panel" - ) - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) + Divider() + .opacity(0.3) - if self.viewModel.isExpanded { - Divider() - .opacity(0.4) + ScrollViewReader { proxy in + ViewThatFits(in: .vertical) { + self.panelContent - ScrollView(.vertical) { - self.expandedContent - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.trailing, 4) + ScrollView(.vertical) { + self.panelContent + .padding(.trailing, 4) + } + .frame(maxHeight: self.contentMaximumHeight) + .scrollBounceBehavior(.basedOnSize) + } + .frame(maxHeight: self.contentMaximumHeight) + .task(id: self.preferredScrollTarget) { + await Task.yield() + guard !Task.isCancelled, + let target = self.preferredScrollTarget + else { + return + } + proxy.scrollTo(target, anchor: .bottom) } - .frame(maxHeight: self.scrollableContentHeight) - .scrollBounceBehavior(.basedOnSize) } } - .padding(16) .frame(width: 500) - .frame(maxHeight: self.maximumHeight) .compatGlass(interactive: true, in: .rect(cornerRadius: 20)) .compatGlassID("youtubeAskPanel", in: self.askPanelNamespace) } .compatGlassTransition(.materialize) .accessibilityElement(children: .contain) .accessibilityIdentifier(AccessibilityID.YouTubeContent.askPanel) - .onAppear { - self.isHeaderFocused = true + .task(id: self.preferredFocusTarget) { + await Task.yield() + guard !Task.isCancelled, + let target = self.preferredFocusTarget + else { + return + } + self.focusedControl = target } } - private var scrollableContentHeight: CGFloat { - max(0, min(520, self.maximumHeight - 128)) + private var panelContent: some View { + VStack(alignment: .leading, spacing: 12) { + Text( + "Responses are generated by YouTube and may be inaccurate.", + comment: "Disclosure shown in the YouTube Ask Gemini panel" + ) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + self.expandedContent + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + .frame(maxWidth: .infinity, alignment: .leading) } - private var header: some View { - Button { - self.viewModel.toggleExpanded() - } label: { - HStack(spacing: 9) { - Image(systemName: "sparkles") - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(.tint) + private var contentMaximumHeight: CGFloat { + max(0, min(520, self.maximumHeight - 58)) + } - Text("Ask Gemini", comment: "YouTube watch-page Ask Gemini panel title") - .font(.headline) - .foregroundStyle(.primary) + private var preferredFocusTarget: FocusTarget? { + // Establish an accessible focus destination only while preparing a fresh, + // empty chat. Once a turn is visible, preserve reply scroll position and + // let keyboard focus move naturally instead of forcing it off-screen. + guard self.viewModel.messages.isEmpty else { return nil } + if self.viewModel.presentationError != nil || self.viewModel.isBusy { + return .close + } + if self.viewModel.acceptsFreeTextInput { + return .input + } + if !self.viewModel.requiresNewChat, + let suggestionID = self.viewModel.suggestions.first?.id + { + return .suggestion(suggestionID) + } + if self.viewModel.canStartNewChat { + return .newChat + } + return .close + } - Spacer(minLength: 8) + private var preferredScrollTarget: ScrollTarget? { + if self.viewModel.presentationError != nil || self.viewModel.activity != .idle { + return .status + } + if let messageID = self.viewModel.messages.last?.id { + return .message(messageID) + } + if !self.viewModel.requiresNewChat, + let suggestionID = self.viewModel.suggestions.first?.id + { + return .suggestion(suggestionID) + } + if self.viewModel.canStartNewChat { + return .newChat + } + return nil + } + + private var headerRow: some View { + HStack(spacing: 12) { + Image(systemName: "sparkles") + .font(.system(size: 16)) + .foregroundStyle(.tint) + + Text("Ask Gemini") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(.primary) + + Spacer(minLength: 0) - Image(systemName: self.viewModel.isExpanded ? "chevron.up" : "chevron.down") - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(.secondary) + self.closeButton + } + } + + private func inputRow(text: Binding) -> some View { + HStack(spacing: 12) { + Image(systemName: "sparkles") + .font(.system(size: 16)) + .foregroundStyle(.tint) + + TextField(String(localized: "Ask about this video..."), text: text) + .textFieldStyle(.plain) + .font(.system(size: 16)) + .focused(self.$focusedControl, equals: .input) + .onSubmit { + guard self.viewModel.canSubmitInput else { return } + self.viewModel.submitInput( + playerOffsetMilliseconds: self.playerOffsetMilliseconds + ) + } + .disabled(!self.viewModel.acceptsFreeTextInput) + .accessibilityIdentifier(AccessibilityID.YouTubeContent.askInput) + + if self.viewModel.isBusy { + ProgressView() + .controlSize(.small) + .scaleEffect(0.7) + .frame(width: 11, height: 11) + } else if !self.viewModel.inputText.isEmpty { + Button { + self.viewModel.submitInput( + playerOffsetMilliseconds: self.playerOffsetMilliseconds + ) + } label: { + Image(systemName: "paperplane.fill") + .foregroundStyle(.tint) + } + .buttonStyle(.plain) + .disabled(!self.viewModel.canSubmitInput) + .accessibilityLabel(String(localized: "Send")) + .accessibilityIdentifier(AccessibilityID.YouTubeContent.askSend) } - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(Rectangle()) + + self.closeButton + } + } + + private var closeButton: some View { + Button { + self.viewModel.setExpanded(false) + } label: { + Image(systemName: "xmark") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.secondary) } .buttonStyle(.plain) - .focused(self.$isHeaderFocused) - .accessibilityLabel( - self.viewModel.isExpanded - ? String(localized: "Collapse Ask Gemini") - : String(localized: "Expand Ask Gemini") - ) + .focused(self.$focusedControl, equals: .close) + .accessibilityLabel(String(localized: "Collapse Ask Gemini")) .accessibilityIdentifier(AccessibilityID.YouTubeContent.askToggle) } @@ -237,8 +379,10 @@ struct YouTubeAskPanelView: View { if let error = self.viewModel.presentationError { self.errorStatus(error) + .id(ScrollTarget.status) } else if self.viewModel.activity != .idle { self.progressStatus + .id(ScrollTarget.status) } if !self.viewModel.suggestions.isEmpty, !self.viewModel.requiresNewChat { @@ -256,30 +400,22 @@ struct YouTubeAskPanelView: View { } .buttonStyle(.bordered) .disabled(self.viewModel.isBusy) + .focused(self.$focusedControl, equals: .newChat) + .id(ScrollTarget.newChat) .accessibilityIdentifier(AccessibilityID.YouTubeContent.askNewChat) } } } private var transcript: some View { - ScrollViewReader { proxy in - ScrollView(.vertical) { - LazyVStack(alignment: .leading, spacing: 10) { - ForEach(self.viewModel.messages) { message in - self.messageView(message) - .id(message.id) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.trailing, 4) - } - .frame(maxHeight: 240) - .accessibilityIdentifier(AccessibilityID.YouTubeContent.askTranscript) - .onChange(of: self.viewModel.messages.map(\.id)) { _, messageIDs in - guard let lastID = messageIDs.last else { return } - proxy.scrollTo(lastID, anchor: .bottom) + LazyVStack(alignment: .leading, spacing: 10) { + ForEach(self.viewModel.messages) { message in + self.messageView(message) + .id(ScrollTarget.message(message.id)) } } + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityIdentifier(AccessibilityID.YouTubeContent.askTranscript) } @ViewBuilder @@ -317,24 +453,30 @@ struct YouTubeAskPanelView: View { } private var suggestions: some View { - VStack(alignment: .leading, spacing: 8) { + LazyVGrid( + columns: [GridItem(.adaptive(minimum: 180), spacing: 8)], + alignment: .leading, + spacing: 8 + ) { ForEach(Array(self.viewModel.suggestions.enumerated()), id: \.element.id) { index, suggestion in Button { self.viewModel.selectSuggestion(id: suggestion.id) } label: { Text(verbatim: suggestion.text) - .font(.system(size: 12, weight: .medium)) + .font(.caption.weight(.medium)) .foregroundStyle(.primary) .multilineTextAlignment(.leading) .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 10) + .padding(.vertical, 7) .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 11) - .padding(.vertical, 9) - .background(.quaternary.opacity(0.55), in: .rect(cornerRadius: 10)) - .contentShape(.rect(cornerRadius: 10)) + .background(.quaternary, in: Capsule()) + .contentShape(Capsule()) } .buttonStyle(.plain) .disabled(self.viewModel.isBusy) + .focused(self.$focusedControl, equals: .suggestion(suggestion.id)) + .id(ScrollTarget.suggestion(suggestion.id)) .accessibilityIdentifier(AccessibilityID.YouTubeContent.askSuggestion(index: index)) } } @@ -445,6 +587,8 @@ extension AccessibilityID.YouTubeContent { static let askButton = "youtubeContent.askButton" static let askOverlay = "youtubeContent.askOverlay" static let askPanel = "youtubeContent.askPanel" + static let askInput = "youtubeContent.askInput" + static let askSend = "youtubeContent.askSend" static let askToggle = "youtubeContent.askToggle" static let askTranscript = "youtubeContent.askTranscript" static let askNewChat = "youtubeContent.askNewChat" diff --git a/Sources/Kaset/Views/YouTube/YouTubeWatchView.swift b/Sources/Kaset/Views/YouTube/YouTubeWatchView.swift index a05d2802e..0dfe26942 100644 --- a/Sources/Kaset/Views/YouTube/YouTubeWatchView.swift +++ b/Sources/Kaset/Views/YouTube/YouTubeWatchView.swift @@ -117,7 +117,10 @@ struct YouTubeWatchView: View { } } .overlay { - YouTubeAskFloatingOverlay(viewModel: self.viewModel.ask) + YouTubeAskFloatingOverlay( + viewModel: self.viewModel.ask, + playerOffsetMilliseconds: self.askPlayerOffsetMilliseconds + ) } .youtubeAskAccessibilityAnnouncements(viewModel: self.viewModel.ask) #if DEBUG @@ -142,6 +145,15 @@ struct YouTubeWatchView: View { } } + private var askPlayerOffsetMilliseconds: Int64 { + guard self.youtubePlayer.currentVideo?.videoId == self.video.videoId, + self.youtubePlayer.progress.isFinite + else { + return 0 + } + return Int64(max(0, self.youtubePlayer.progress * 1000).rounded()) + } + // MARK: - Ambient Style Picker (PROTOTYPE) #if DEBUG diff --git a/Sources/YouTubeAskCore/YouTubeAskCoreError.swift b/Sources/YouTubeAskCore/YouTubeAskCoreError.swift index eff704b60..87573a014 100644 --- a/Sources/YouTubeAskCore/YouTubeAskCoreError.swift +++ b/Sources/YouTubeAskCore/YouTubeAskCoreError.swift @@ -15,4 +15,7 @@ package enum YouTubeAskCoreError: Error, Equatable, Sendable { case unsupportedChipDecorator case malformedMessage case invalidClientMessageID + case invalidUserInput + case requestTooLarge + case missingFreeTextCommandContext } diff --git a/Sources/YouTubeAskCore/YouTubeAskLimits.swift b/Sources/YouTubeAskCore/YouTubeAskLimits.swift index aeabdb66d..9dc50fa68 100644 --- a/Sources/YouTubeAskCore/YouTubeAskLimits.swift +++ b/Sources/YouTubeAskCore/YouTubeAskLimits.swift @@ -6,6 +6,9 @@ package enum YouTubeAskLimits { package static let maximumFrames = 256 package static let maximumChipCharacters = 200 package static let maximumAnswerCharacters = 16000 + package static let maximumUserInputCharacters = 16000 + package static let maximumUserInputBytes = 64 * 1024 + package static let maximumRequestBodyBytes = 2 * 1024 * 1024 static let maximumTreeDepth = 80 static let maximumTreeNodes = 100_000 diff --git a/Sources/YouTubeAskCore/YouTubeAskOpaqueCommand.swift b/Sources/YouTubeAskCore/YouTubeAskOpaqueCommand.swift index aad9ced66..f30e5bedb 100644 --- a/Sources/YouTubeAskCore/YouTubeAskOpaqueCommand.swift +++ b/Sources/YouTubeAskCore/YouTubeAskOpaqueCommand.swift @@ -6,6 +6,17 @@ import Foundation /// outside this module and has only redacted string/reflection representations. package struct YouTubeAskOpaqueCommand: Sendable { let continuation: String + let clickTrackingParams: String? + + package init(_ continuation: String) { + self.continuation = continuation + self.clickTrackingParams = nil + } + + package init(continuation: String, clickTrackingParams: String) { + self.continuation = continuation + self.clickTrackingParams = clickTrackingParams + } } // MARK: CustomStringConvertible diff --git a/Sources/YouTubeAskCore/YouTubeAskParsedModels.swift b/Sources/YouTubeAskCore/YouTubeAskParsedModels.swift index 7189c7d06..37aa78100 100644 --- a/Sources/YouTubeAskCore/YouTubeAskParsedModels.swift +++ b/Sources/YouTubeAskCore/YouTubeAskParsedModels.swift @@ -11,6 +11,7 @@ package struct YouTubeAskParsedSuggestion: Sendable { package struct YouTubeAskParsedBootstrap: Sendable { package let panelCommand: YouTubeAskOpaqueCommand? + package let freeTextCommand: YouTubeAskOpaqueCommand? package let suggestions: [YouTubeAskParsedSuggestion] } diff --git a/Sources/YouTubeAskCore/YouTubeAskParser+FreeText.swift b/Sources/YouTubeAskCore/YouTubeAskParser+FreeText.swift new file mode 100644 index 000000000..c6a401990 --- /dev/null +++ b/Sources/YouTubeAskCore/YouTubeAskParser+FreeText.swift @@ -0,0 +1,93 @@ +import Foundation + +extension YouTubeAskParser { + static func collectFreeTextCommands( + in value: YouTubeAskJSONValue, + depth: Int, + budget: inout TraversalBudget, + commands: inout [YouTubeAskOpaqueCommand] + ) throws { + try budget.visit(value, depth: depth) + switch value { + case let .object(object): + if let viewModel = object["youChatItemViewModel"]?.objectValue, + let command = viewModel["sendUserQueryCommand"] + { + try commands.append(Self.parseFreeTextCommand(command)) + } + for key in object.keys.sorted() { + guard let nested = object[key] else { continue } + try Self.collectFreeTextCommands( + in: nested, + depth: depth + 1, + budget: &budget, + commands: &commands + ) + } + case let .array(array): + for nested in array { + try Self.collectFreeTextCommands( + in: nested, + depth: depth + 1, + budget: &budget, + commands: &commands + ) + } + default: + break + } + } + + static func parseFreeTextCommand( + _ value: YouTubeAskJSONValue + ) throws -> YouTubeAskOpaqueCommand { + guard let command = value.objectValue, + Set(command.keys) == ["innertubeCommand"], + let innertubeCommand = command["innertubeCommand"]?.objectValue, + Set(innertubeCommand.keys) == ["clickTrackingParams", "continuationCommand"], + let clickTrackingParams = innertubeCommand["clickTrackingParams"]?.stringValue, + !clickTrackingParams.isEmpty, + clickTrackingParams.count <= YouTubeAskLimits.maximumCommandCharacters, + let continuationCommand = innertubeCommand["continuationCommand"]?.objectValue, + Set(continuationCommand.keys) == ["request", "token"], + continuationCommand["request"]?.stringValue + == "CONTINUATION_REQUEST_TYPE_GET_PANEL", + let continuation = continuationCommand["token"]?.stringValue, + !continuation.isEmpty, + continuation.count <= YouTubeAskLimits.maximumCommandCharacters + else { + throw YouTubeAskCoreError.malformedChip + } + return YouTubeAskOpaqueCommand( + continuation: continuation, + clickTrackingParams: clickTrackingParams + ) + } + + static func unambiguousFreeTextCommand( + _ commands: [YouTubeAskOpaqueCommand] + ) throws -> YouTubeAskOpaqueCommand? { + guard let first = commands.first else { return nil } + guard commands.dropFirst().allSatisfy({ command in + Self.freeTextCommandsMatch(first, command) + }) else { + throw YouTubeAskCoreError.ambiguousBootstrap + } + return first + } + + static func freeTextCommandsMatch( + _ lhs: YouTubeAskOpaqueCommand?, + _ rhs: YouTubeAskOpaqueCommand? + ) -> Bool { + switch (lhs, rhs) { + case (nil, nil): + true + case let (lhs?, rhs?): + lhs.continuation == rhs.continuation + && lhs.clickTrackingParams == rhs.clickTrackingParams + default: + false + } + } +} diff --git a/Sources/YouTubeAskCore/YouTubeAskParser+Suggestions.swift b/Sources/YouTubeAskCore/YouTubeAskParser+Suggestions.swift new file mode 100644 index 000000000..6d9f13872 --- /dev/null +++ b/Sources/YouTubeAskCore/YouTubeAskParser+Suggestions.swift @@ -0,0 +1,40 @@ +import Foundation + +extension YouTubeAskParser { + static func deduplicatedSuggestions( + _ suggestions: [YouTubeAskParsedSuggestion] + ) throws -> [YouTubeAskParsedSuggestion] { + var firstIndexByLabel: [String: Int] = [:] + var result: [YouTubeAskParsedSuggestion] = [] + result.reserveCapacity(suggestions.count) + + for suggestion in suggestions { + if let firstIndex = firstIndexByLabel[suggestion.label] { + // The visible label cannot safely disambiguate distinct opaque + // capabilities. Keep exact repeats, but fail closed on a label + // collision instead of silently choosing one command. + guard result[firstIndex].command.continuation == suggestion.command.continuation else { + throw YouTubeAskCoreError.malformedChip + } + continue + } + + firstIndexByLabel[suggestion.label] = result.count + result.append(suggestion) + } + + return result + } + + static let eligibleMarkerValues: Set = [ + "PAyouchat", + "engagement-panel-youchat", + ] + + static let eligibleMarkerKeys: Set = [ + "identifier", + "panelId", + "panelIdentifier", + "targetId", + ] +} diff --git a/Sources/YouTubeAskCore/YouTubeAskParser+VisibleText.swift b/Sources/YouTubeAskCore/YouTubeAskParser+VisibleText.swift new file mode 100644 index 000000000..c03fa715a --- /dev/null +++ b/Sources/YouTubeAskCore/YouTubeAskParser+VisibleText.swift @@ -0,0 +1,45 @@ +import Foundation + +extension YouTubeAskParser { + static func visibleText( + from value: YouTubeAskJSONValue, + malformedError: YouTubeAskCoreError + ) throws -> String { + if let string = value.stringValue { + return string + } + guard let object = value.objectValue else { + throw malformedError + } + + let recognizedKeys = ["content", "simpleText", "runs"] + .filter { object[$0] != nil } + guard recognizedKeys.count == 1, let selectedKey = recognizedKeys.first else { + throw malformedError + } + + switch selectedKey { + case "content", "simpleText": + guard let text = object[selectedKey]?.stringValue else { + throw malformedError + } + return text + case "runs": + guard let runs = object["runs"]?.arrayValue, !runs.isEmpty else { + throw malformedError + } + var text = "" + for run in runs { + guard let runObject = run.objectValue, + let runText = runObject["text"]?.stringValue + else { + throw malformedError + } + text.append(contentsOf: runText) + } + return text + default: + throw malformedError + } + } +} diff --git a/Sources/YouTubeAskCore/YouTubeAskParser.swift b/Sources/YouTubeAskCore/YouTubeAskParser.swift index 86baa96a2..28d51516a 100644 --- a/Sources/YouTubeAskCore/YouTubeAskParser.swift +++ b/Sources/YouTubeAskCore/YouTubeAskParser.swift @@ -19,7 +19,10 @@ package enum YouTubeAskParser { guard !eligiblePanels.isEmpty else { return nil } var continuationCandidates: [String] = [] - var content = ConversationAccumulator() + var canonicalContent: ( + suggestions: [YouTubeAskParsedSuggestion], + freeTextCommand: YouTubeAskOpaqueCommand? + )? for panel in eligiblePanels { var commandBudget = TraversalBudget() try Self.collectBootstrapContinuations( @@ -30,28 +33,69 @@ package enum YouTubeAskParser { continuations: &continuationCandidates ) + var panelContent = ConversationAccumulator() var contentBudget = TraversalBudget() try Self.collectBootstrapSuggestions( in: panel, depth: 0, budget: &contentBudget, - content: &content + content: &panelContent + ) + var freeTextBudget = TraversalBudget() + var freeTextCommands: [YouTubeAskOpaqueCommand] = [] + try Self.collectFreeTextCommands( + in: panel, + depth: 0, + budget: &freeTextBudget, + commands: &freeTextCommands ) + let panelFreeTextCommand = try Self.unambiguousFreeTextCommand(freeTextCommands) + + let panelSuggestions = try Self.deduplicatedSuggestions(panelContent.suggestions) + guard !panelSuggestions.isEmpty || panelFreeTextCommand != nil else { + continue + } + if let canonicalContent { + guard canonicalContent.suggestions.map(\.label) == panelSuggestions.map(\.label) else { + throw YouTubeAskCoreError.malformedChip + } + guard Self.freeTextCommandsMatch( + canonicalContent.freeTextCommand, + panelFreeTextCommand + ) else { + throw YouTubeAskCoreError.ambiguousBootstrap + } + } else { + canonicalContent = ( + suggestions: panelSuggestions, + freeTextCommand: panelFreeTextCommand + ) + } } + let suggestions = canonicalContent?.suggestions ?? [] + let freeTextCommand = canonicalContent?.freeTextCommand let panelContinuation: String? do { panelContinuation = try Self.unambiguousContinuation(continuationCandidates) - } catch YouTubeAskCoreError.ambiguousBootstrap where !content.suggestions.isEmpty { + } catch YouTubeAskCoreError.ambiguousBootstrap + where !suggestions.isEmpty || freeTextCommand != nil + { // Direct chip continuations are independently validated capabilities. // Do not guess among unrelated panel-bootstrap commands when the // panel can already be presented without materialization. panelContinuation = nil } - guard panelContinuation != nil || !content.suggestions.isEmpty else { return nil } + guard panelContinuation != nil + || freeTextCommand != nil + || !suggestions.isEmpty + else { + return nil + } return YouTubeAskParsedBootstrap( panelCommand: panelContinuation.map(YouTubeAskOpaqueCommand.init), - suggestions: content.suggestions + freeTextCommand: freeTextCommand, + suggestions: suggestions ) } @@ -76,6 +120,7 @@ package enum YouTubeAskParser { content: &content ) } + content.suggestions = try Self.deduplicatedSuggestions(content.suggestions) return YouTubeAskParsedConversation( messages: content.messages, suggestions: content.suggestions @@ -87,7 +132,7 @@ package enum YouTubeAskParser { var suggestions: [YouTubeAskParsedSuggestion] = [] } - private struct TraversalBudget { + struct TraversalBudget { var visitedNodes = 0 mutating func visit(_ value: YouTubeAskJSONValue, depth: Int) throws { @@ -109,18 +154,6 @@ package enum YouTubeAskParser { } } - private static let eligibleMarkerValues: Set = [ - "PAyouchat", - "engagement-panel-youchat", - ] - - private static let eligibleMarkerKeys: Set = [ - "identifier", - "panelId", - "panelIdentifier", - "targetId", - ] - private static func collectEligiblePanels( in value: YouTubeAskJSONValue, depth: Int, @@ -471,7 +504,7 @@ package enum YouTubeAskParser { } return YouTubeAskParsedSuggestion( label: label.text, - command: YouTubeAskOpaqueCommand(continuation: continuation) + command: YouTubeAskOpaqueCommand(continuation) ) } @@ -496,48 +529,6 @@ package enum YouTubeAskParser { ) } - private static func visibleText( - from value: YouTubeAskJSONValue, - malformedError: YouTubeAskCoreError - ) throws -> String { - if let string = value.stringValue { - return string - } - guard let object = value.objectValue else { - throw malformedError - } - - let recognizedKeys = ["content", "simpleText", "runs"] - .filter { object[$0] != nil } - guard recognizedKeys.count == 1, let selectedKey = recognizedKeys.first else { - throw malformedError - } - - switch selectedKey { - case "content", "simpleText": - guard let text = object[selectedKey]?.stringValue else { - throw malformedError - } - return text - case "runs": - guard let runs = object["runs"]?.arrayValue, !runs.isEmpty else { - throw malformedError - } - var text = "" - for run in runs { - guard let runObject = run.objectValue, - let runText = runObject["text"]?.stringValue - else { - throw malformedError - } - text.append(contentsOf: runText) - } - return text - default: - throw malformedError - } - } - private static func containsUnsupportedChipDecorator( _ value: YouTubeAskJSONValue, expectedVisibleText: String diff --git a/Sources/YouTubeAskCore/YouTubeAskRequestBuilder.swift b/Sources/YouTubeAskCore/YouTubeAskRequestBuilder.swift index 0656b02b7..19312f72e 100644 --- a/Sources/YouTubeAskCore/YouTubeAskRequestBuilder.swift +++ b/Sources/YouTubeAskCore/YouTubeAskRequestBuilder.swift @@ -32,6 +32,58 @@ package enum YouTubeAskRequestBuilder { return try JSONEncoder().encode(body) } + package static func makeFreeTextBody( + command: YouTubeAskOpaqueCommand, + clientMessageID: String, + userInputText: String, + playerOffsetMilliseconds: Int64 + ) throws -> Data { + guard self.isValidClientMessageID(clientMessageID) else { + throw YouTubeAskCoreError.invalidClientMessageID + } + let trimmedInput = userInputText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedInput.isEmpty, + trimmedInput.count <= YouTubeAskLimits.maximumUserInputCharacters, + trimmedInput.utf8.count <= YouTubeAskLimits.maximumUserInputBytes + else { + throw YouTubeAskCoreError.invalidUserInput + } + guard command.clickTrackingParams != nil else { + throw YouTubeAskCoreError.missingFreeTextCommandContext + } + + let body = FreeTextBody( + continuation: command.continuation, + formData: FreeTextFormData( + inputComposerFormData: FreeTextInputComposerFormData( + clientMessageId: clientMessageID, + playerOffsetMs: String(max(0, playerOffsetMilliseconds)), + userInputText: trimmedInput + ) + ) + ) + let data = try JSONEncoder().encode(body) + try Self.validateRequestBodySize(data) + return data + } + + package static func makeFreeTextClickTrackingContext( + command: YouTubeAskOpaqueCommand + ) throws -> Data { + guard let clickTrackingParams = command.clickTrackingParams else { + throw YouTubeAskCoreError.missingFreeTextCommandContext + } + return try JSONEncoder().encode(ClickTrackingContext( + clickTracking: ClickTracking(clickTrackingParams: clickTrackingParams) + )) + } + + package static func validateRequestBodySize(_ data: Data) throws { + guard data.count <= YouTubeAskLimits.maximumRequestBodyBytes else { + throw YouTubeAskCoreError.requestTooLarge + } + } + private static func isValidClientMessageID(_ value: String) -> Bool { let bytes = Array(value.utf8) let prefix = Array("youchat-".utf8) @@ -62,4 +114,27 @@ package enum YouTubeAskRequestBuilder { private struct InputComposerFormData: Encodable { let clientMessageId: String } + + private struct FreeTextBody: Encodable { + let continuation: String + let formData: FreeTextFormData + } + + private struct FreeTextFormData: Encodable { + let inputComposerFormData: FreeTextInputComposerFormData + } + + private struct FreeTextInputComposerFormData: Encodable { + let clientMessageId: String + let playerOffsetMs: String + let userInputText: String + } + + private struct ClickTrackingContext: Encodable { + let clickTracking: ClickTracking + } + + private struct ClickTracking: Encodable { + let clickTrackingParams: String + } } diff --git a/Tests/KasetTests/AppLocalizationTests.swift b/Tests/KasetTests/AppLocalizationTests.swift index 8c413e106..9046e8f87 100644 --- a/Tests/KasetTests/AppLocalizationTests.swift +++ b/Tests/KasetTests/AppLocalizationTests.swift @@ -383,6 +383,8 @@ struct AppLocalizationTests { let expectedValues = [ ("ar", "New Chat", "محادثة جديدة"), ("de", "Ask Gemini", "Gemini fragen"), + ("de", "Ask about this video...", "Frag etwas zu diesem Video…"), + ("fr", "Send", "Envoyer"), ("ko", "YouTube response: %@", "YouTube 응답: %@"), ("tr", "Sending…", "Gönderiliyor…"), ] diff --git a/Tests/KasetTests/Helpers/MockYouTubeClient.swift b/Tests/KasetTests/Helpers/MockYouTubeClient.swift index b162e3790..d02b3d478 100644 --- a/Tests/KasetTests/Helpers/MockYouTubeClient.swift +++ b/Tests/KasetTests/Helpers/MockYouTubeClient.swift @@ -230,6 +230,9 @@ final class MockYouTubeClient: YouTubeClientProtocol { private(set) var getWatchPageCallCount = 0 private(set) var loadAskConversationCallCount = 0 private(set) var continueAskConversationCallCount = 0 + private(set) var continueAskFreeTextCallCount = 0 + private(set) var submittedAskFreeTextInputs: [String] = [] + private(set) var submittedAskPlayerOffsets: [Int64] = [] private(set) var selectedAskSuggestionIDs: [YouTubeAskSuggestion.ID] = [] var beforeWatchPageReturn: (@Sendable () async -> Void)? var beforeWatchPageReturnByCallCount: (@Sendable (Int) async -> Void)? @@ -271,10 +274,10 @@ final class MockYouTubeClient: YouTubeClientProtocol { await beforeAskPreparationReturn() } try Task.checkCancellation() - if self.askConversation.suggestions.isEmpty, !bootstrap.suggestions.isEmpty { - return YouTubeAskConversation.testing( - suggestions: bootstrap.suggestions.map(\.text) - ) + if self.askConversation.messages.isEmpty, + self.askConversation.suggestions.isEmpty + { + return YouTubeAskConversation.direct(from: bootstrap) } return self.askConversation } @@ -298,6 +301,27 @@ final class MockYouTubeClient: YouTubeClientProtocol { return YouTubeAskConversation.testing(messages: conversation.messages) } + func continueAskConversation( + _ conversation: YouTubeAskConversation, + submitting userInputText: String, + playerOffsetMilliseconds: Int64 + ) async throws -> YouTubeAskConversation { + self.continueAskFreeTextCallCount += 1 + self.submittedAskFreeTextInputs.append(userInputText) + self.submittedAskPlayerOffsets.append(playerOffsetMilliseconds) + if let askError { + throw askError + } + if let beforeAskContinuationReturn { + await beforeAskContinuationReturn() + } + try Task.checkCancellation() + if let continuedAskConversation { + return continuedAskConversation + } + return YouTubeAskConversation.testing(messages: conversation.messages) + } + var commentsPage = YouTubeCommentsPage.empty private(set) var postedComments: [(text: String, params: String)] = [] private(set) var lastCommentsContinuation: String? diff --git a/Tests/KasetTests/MainWindowLayoutTests.swift b/Tests/KasetTests/MainWindowLayoutTests.swift index d502fcd0e..28b7ef74e 100644 --- a/Tests/KasetTests/MainWindowLayoutTests.swift +++ b/Tests/KasetTests/MainWindowLayoutTests.swift @@ -4,6 +4,11 @@ import Testing @Suite("Main window layout", .serialized) struct MainWindowLayoutTests { + @Test("AI task surfaces use the shared 72-point top inset") + func aiTaskSurfaceTopPadding() { + #expect(MainWindowLayout.aiTaskSurfaceTopPadding == 72) + } + @Test("Clamps undersized restored content frames") func clampsUndersizedContentFrames() { let clamped = MainWindowLayout.clampedContentSize(NSSize(width: 640, height: 420)) diff --git a/Tests/KasetTests/YouTubeAskClientFreeTextTests.swift b/Tests/KasetTests/YouTubeAskClientFreeTextTests.swift new file mode 100644 index 000000000..f926445f2 --- /dev/null +++ b/Tests/KasetTests/YouTubeAskClientFreeTextTests.swift @@ -0,0 +1,89 @@ +import Foundation +import Testing +@testable import Kaset + +extension YouTubeAskClientTests { + @Test("Free text sends the browser-validated get_panel body and click context") + @MainActor + func freeTextRequestShape() async throws { + let requestCount = LockedCounter() + let session = MockURLProtocol.makeMockSession { request in + switch requestCount.increment() { + case 1: + return Self.response(for: request, data: Self.eligibleNextData) + case 2: + #expect(request.url?.path == "/youtubei/v1/get_panel") + #expect(request.url?.query?.contains("key=") != true) + let body = try Self.body(from: request) + #expect(Set(body.keys) == ["context", "continuation", "formData"]) + #expect(body["continuation"] as? String == "fixture-free-text-continuation") + + let context = try #require(body["context"] as? [String: Any]) + let clickTracking = try #require(context["clickTracking"] as? [String: Any]) + #expect(Set(clickTracking.keys) == ["clickTrackingParams"]) + #expect( + clickTracking["clickTrackingParams"] as? String + == "fixture-free-text-click-tracking" + ) + + let formData = try #require(body["formData"] as? [String: Any]) + #expect(Set(formData.keys) == ["inputComposerFormData"]) + let composer = try #require(formData["inputComposerFormData"] as? [String: Any]) + #expect(Set(composer.keys) == ["clientMessageId", "playerOffsetMs", "userInputText"]) + #expect(composer["clientMessageId"] as? String == "youchat-1000") + #expect(composer["playerOffsetMs"] as? String == "234000") + #expect(composer["userInputText"] as? String == "What is this video about?") + return Self.response(for: request, data: Self.mutationConversationData) + default: + Issue.record("Free-text Ask request retried unexpectedly") + return Self.response(for: request, data: Data(#"{}"#.utf8)) + } + } + defer { MockURLProtocol.reset(session: session) } + + let generator = YouTubeAskMessageIDGenerator(nowMilliseconds: { 1000 }) + let (client, _) = try await Self.makeAuthenticatedClient( + session: session, + messageIDGenerator: generator + ) + let page = try await client.getWatchPage(videoId: "fixture-video") + let conversation = try await client.loadAskConversation( + from: #require(page.askBootstrap) + ) + #expect(conversation.canSubmitFreeText) + let oversizedUTF8 = "a" + String( + repeating: "\u{0301}", + count: 64 * 1024 + ) + #expect(conversation.appendingUserTurn(text: oversizedUTF8) == nil) + let pendingConversation = try #require( + conversation.appendingUserTurn(text: "What is this video about?") + ) + + await #expect(throws: CancellationError.self) { + _ = try await client.continueAskConversation( + pendingConversation, + submitting: "Answer a different question", + playerOffsetMilliseconds: 234_000 + ) + } + #expect(requestCount.count == 1) + + let continued = try await client.continueAskConversation( + pendingConversation, + submitting: "What is this video about?", + playerOffsetMilliseconds: 234_000 + ) + + await #expect(throws: CancellationError.self) { + _ = try await client.continueAskConversation( + pendingConversation, + submitting: "What is this video about?", + playerOffsetMilliseconds: 234_000 + ) + } + #expect(requestCount.count == 2) + #expect(!continued.canSubmitFreeText) + #expect(continued.messages.map(\.role).count == 2) + } +} diff --git a/Tests/KasetTests/YouTubeAskClientTests.swift b/Tests/KasetTests/YouTubeAskClientTests.swift index 378a05f6f..230a9ff3d 100644 --- a/Tests/KasetTests/YouTubeAskClientTests.swift +++ b/Tests/KasetTests/YouTubeAskClientTests.swift @@ -573,7 +573,7 @@ struct YouTubeAskClientTests { } @MainActor - private static func makeAuthenticatedClient( + static func makeAuthenticatedClient( session: URLSession, messageIDGenerator: YouTubeAskMessageIDGenerator? = nil ) async throws -> (YouTubeClient, AuthService) { @@ -600,7 +600,7 @@ struct YouTubeAskClientTests { return (client, authService) } - private static func body(from request: URLRequest) throws -> [String: Any] { + static func body(from request: URLRequest) throws -> [String: Any] { let data: Data if let httpBody = request.httpBody { data = httpBody @@ -624,7 +624,7 @@ struct YouTubeAskClientTests { return try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) } - private static func response( + static func response( for request: URLRequest, data: Data, statusCode: Int = 200 @@ -642,7 +642,7 @@ struct YouTubeAskClientTests { return (response, data) } - private static let eligibleNextData = Data( + static let eligibleNextData = Data( #""" { "contents": {}, @@ -663,6 +663,15 @@ struct YouTubeAskClientTests { "continuation": "fixture-chip-b" } ] + }, + "sendUserQueryCommand": { + "innertubeCommand": { + "clickTrackingParams": "fixture-free-text-click-tracking", + "continuationCommand": { + "request": "CONTINUATION_REQUEST_TYPE_GET_PANEL", + "token": "fixture-free-text-continuation" + } + } } } } diff --git a/Tests/KasetTests/YouTubeAskViewModelTests.swift b/Tests/KasetTests/YouTubeAskViewModelTests.swift index b90e24a09..8bcd9d1c4 100644 --- a/Tests/KasetTests/YouTubeAskViewModelTests.swift +++ b/Tests/KasetTests/YouTubeAskViewModelTests.swift @@ -48,6 +48,65 @@ struct YouTubeAskViewModelTests { #expect(sut.suggestions.map(\.text) == ["Explain the main idea"]) } + @Test("Free text is single-flight, forwards playback offset, and is one-shot") + func freeTextSubmissionIsValidatedAndOneShot() async { + let client = MockYouTubeClient() + let sut = YouTubeAskViewModel(videoID: "fixture-video", client: client) + sut.seed(YouTubeAskBootstrap.testing( + suggestions: ["Explain the main idea"], + allowsFreeText: true + )) + sut.setExpanded(true) + await self.waitUntil(sut.activity == .idle && sut.acceptsFreeTextInput) + + sut.inputText = String(repeating: "a", count: 16001) + #expect(!sut.canSubmitInput) + let oversizedUTF8 = String(repeating: "🇺🇸", count: 9000) + #expect(oversizedUTF8.count <= 16000) + #expect(oversizedUTF8.utf8.count > 64 * 1024) + sut.inputText = oversizedUTF8 + #expect(!sut.canSubmitInput) + sut.inputText = "" + + client.continuedAskConversation = YouTubeAskConversation.testing( + messages: [ + YouTubeAskMessage(role: .user, text: "What is this video about?"), + YouTubeAskMessage(role: .assistant, text: "A fixture answer."), + ], + suggestions: ["Ask a follow-up"] + ) + let gate = AsyncGate() + client.beforeAskContinuationReturn = { + await gate.wait() + } + sut.inputText = " What is this video about? " + + sut.submitInput(playerOffsetMilliseconds: 234_000) + await self.waitUntil(client.continueAskFreeTextCallCount == 1) + + #expect(sut.activity == .sending) + #expect(sut.inputText.isEmpty) + #expect(sut.messages.map(\.text) == ["What is this video about?"]) + #expect(client.submittedAskFreeTextInputs == ["What is this video about?"]) + #expect(client.submittedAskPlayerOffsets == [234_000]) + + sut.inputText = "Ignored second prompt" + sut.submitInput(playerOffsetMilliseconds: 0) + await Task.yield() + #expect(client.continueAskFreeTextCallCount == 1) + + await gate.open() + await self.waitUntil(sut.activity == .idle && sut.messages.count == 2) + + #expect(sut.messages.map(\.text) == [ + "What is this video about?", + "A fixture answer.", + ]) + #expect(!sut.acceptsFreeTextInput) + #expect(sut.canStartNewChat) + #expect(sut.accessibilityAnnouncement == .responseReady) + } + @Test("Suggestion selection publishes the user turn, stays single-flight, and preserves server order") func selectionIsSingleFlightAndOrdered() async throws { let client = MockYouTubeClient() diff --git a/Tests/KasetTests/YouTubeSingleFlightViewModelTests.swift b/Tests/KasetTests/YouTubeSingleFlightViewModelTests.swift index fe7d596d2..f773a8e99 100644 --- a/Tests/KasetTests/YouTubeSingleFlightViewModelTests.swift +++ b/Tests/KasetTests/YouTubeSingleFlightViewModelTests.swift @@ -353,6 +353,14 @@ private final class SingleFlightYouTubeClient: YouTubeClientProtocol { return conversation } + func continueAskConversation( + _ conversation: YouTubeAskConversation, + submitting _: String, + playerOffsetMilliseconds _: Int64 + ) async throws -> YouTubeAskConversation { + conversation + } + func getComments(continuation _: String) async throws -> YouTubeCommentsPage { try await self.waitIfNeeded() return self.commentsPage diff --git a/Tests/YouTubeAskCoreTests/Fixtures/YouTubeAskEligibleNext.json b/Tests/YouTubeAskCoreTests/Fixtures/YouTubeAskEligibleNext.json index 7530af480..a9f5608be 100644 --- a/Tests/YouTubeAskCoreTests/Fixtures/YouTubeAskEligibleNext.json +++ b/Tests/YouTubeAskCoreTests/Fixtures/YouTubeAskEligibleNext.json @@ -30,6 +30,15 @@ "continuation": "fixture-chip-continuation-b" } ] + }, + "sendUserQueryCommand": { + "innertubeCommand": { + "clickTrackingParams": "fixture-free-text-click-tracking", + "continuationCommand": { + "request": "CONTINUATION_REQUEST_TYPE_GET_PANEL", + "token": "fixture-free-text-continuation" + } + } } } }, diff --git a/Tests/YouTubeAskCoreTests/YouTubeAskFixtureSafetyTests.swift b/Tests/YouTubeAskCoreTests/YouTubeAskFixtureSafetyTests.swift index bdfaca1bc..7137a5338 100644 --- a/Tests/YouTubeAskCoreTests/YouTubeAskFixtureSafetyTests.swift +++ b/Tests/YouTubeAskCoreTests/YouTubeAskFixtureSafetyTests.swift @@ -81,6 +81,102 @@ struct YouTubeAskFixtureSafetyTests { } } + @Test("API Explorer free text delegates parsing and request encoding to YouTubeAskCore") + func apiExplorerUsesSharedFreeTextCore() throws { + let repositoryRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let sourceURL = repositoryRoot + .appendingPathComponent("Sources", isDirectory: true) + .appendingPathComponent("APIExplorer", isDirectory: true) + .appendingPathComponent("AskVideoAudit.swift") + let source = try String(contentsOf: sourceURL, encoding: .utf8) + + let loadStart = try #require(source.range(of: "private func loadAskFreeTextCommand(")) + let sendStart = try #require(source.range( + of: "private func sendAskFreeTextRequest(", + range: loadStart.upperBound ..< source.endIndex + )) + let liveStart = try #require(source.range( + of: "func liveTestAskVideoFreeText(", + range: sendStart.upperBound ..< source.endIndex + )) + let loadFunction = source[loadStart.lowerBound ..< sendStart.lowerBound] + let sendFunction = source[sendStart.lowerBound ..< liveStart.lowerBound] + + #expect(loadFunction.contains("YouTubeAskParser.parseBootstrap")) + #expect(sendFunction.contains("YouTubeAskRequestBuilder.makeFreeTextBody")) + #expect(sendFunction.contains("YouTubeAskRequestBuilder.makeFreeTextClickTrackingContext")) + #expect(!source.contains("private struct AskFreeTextCommand")) + #expect(!source.contains("collectAskFreeTextCommands")) + } + + @Test("API Explorer freezes one authenticated request snapshot for guarded free text") + func apiExplorerFreeTextUsesSingleRequestSnapshot() throws { + let repositoryRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let sourceURL = repositoryRoot + .appendingPathComponent("Sources", isDirectory: true) + .appendingPathComponent("APIExplorer", isDirectory: true) + .appendingPathComponent("AskVideoAudit.swift") + let source = try String(contentsOf: sourceURL, encoding: .utf8) + + let captureStart = try #require(source.range( + of: "private func captureAskFreeTextRequestSnapshot()" + )) + let wireStart = try #require(source.range( + of: "private func makeRuntimeAskFreeTextWireRequest(", + range: captureStart.upperBound ..< source.endIndex + )) + let loadStart = try #require(source.range( + of: "private func loadAskFreeTextCommand(", + range: wireStart.upperBound ..< source.endIndex + )) + let sendStart = try #require(source.range( + of: "private func sendAskFreeTextRequest(", + range: loadStart.upperBound ..< source.endIndex + )) + let liveStart = try #require(source.range( + of: "func liveTestAskVideoFreeText(", + range: sendStart.upperBound ..< source.endIndex + )) + let nextFunctionStart = try #require(source.range( + of: "private func makeAskSummaryRequest(", + range: liveStart.upperBound ..< source.endIndex + )) + + let captureFunction = source[captureStart.lowerBound ..< wireStart.lowerBound] + let wireFunction = source[wireStart.lowerBound ..< loadStart.lowerBound] + let loadFunction = source[loadStart.lowerBound ..< sendStart.lowerBound] + let sendFunction = source[sendStart.lowerBound ..< liveStart.lowerBound] + let liveFunction = source[liveStart.lowerBound ..< nextFunctionStart.lowerBound] + + #expect(captureFunction.contains("resolveAskRuntimeWEBConfiguration(cookies: cookies)")) + #expect(captureFunction.contains("currentAskFreeTextBackingState()")) + #expect(captureFunction.contains("requestSnapshotChanged")) + + #expect(wireFunction.contains("requestSnapshot.contextData")) + #expect(wireFunction.contains("requestSnapshot.headers")) + #expect(wireFunction.contains("requestSnapshot.runtimeAPIIdentifier")) + #expect(wireFunction.contains("validateAskFreeTextRequestSnapshot(requestSnapshot)")) + #expect(!wireFunction.contains("resolveAPIKey(")) + #expect(!wireFunction.contains("buildContext(")) + #expect(!wireFunction.contains("buildHeaders(")) + #expect(!wireFunction.contains("loadCookiesFromAppBackup(")) + + #expect(loadFunction.contains("requestSnapshot: AskFreeTextRequestSnapshot")) + #expect(loadFunction.contains("requestSnapshot: requestSnapshot")) + #expect(sendFunction.contains("requestSnapshot: AskFreeTextRequestSnapshot")) + #expect(sendFunction.contains("validateBackingStateBeforeSending: true")) + #expect(liveFunction.contains("let requestSnapshot = try await captureAskFreeTextRequestSnapshot()")) + #expect(liveFunction.contains("requestSnapshot: requestSnapshot")) + let rawVideoIDOutput = "pri" + "nt(\"Video ID: \\(videoID)\")" + #expect(!liveFunction.contains(rawVideoIDOutput)) + } + private struct Violation { let rule: String let path: String diff --git a/Tests/YouTubeAskCoreTests/YouTubeAskParserMirroredPanelTests.swift b/Tests/YouTubeAskCoreTests/YouTubeAskParserMirroredPanelTests.swift new file mode 100644 index 000000000..718151f2b --- /dev/null +++ b/Tests/YouTubeAskCoreTests/YouTubeAskParserMirroredPanelTests.swift @@ -0,0 +1,141 @@ +import Foundation +import Testing +@testable import YouTubeAskCore + +extension YouTubeAskParserTests { + @Test("Selects the first content-equivalent mirrored Ask panel") + func selectsFirstMirroredBootstrapPanel() throws { + let envelope = try Self.envelope([ + "engagementPanels": [ + Self.eligiblePanel(chips: [ + ("Summarize the video", "fixture-summary-primary"), + ("Recommend related content", "fixture-related-primary"), + ]), + Self.eligiblePanel(chips: [ + ("Summarize the video", "fixture-summary-mirror"), + ("Recommend related content", "fixture-related-mirror"), + ]), + ], + ]) + + let parsedBootstrap = try YouTubeAskParser.parseBootstrap(from: envelope) + let bootstrap = try #require(parsedBootstrap) + + #expect(bootstrap.suggestions.map(\.label) == [ + "Summarize the video", + "Recommend related content", + ]) + #expect(bootstrap.suggestions.map(\.command.continuation) == [ + "fixture-summary-primary", + "fixture-related-primary", + ]) + } + + @Test("Rejects mirrored Ask panels with different visible choices") + func rejectsInconsistentMirroredBootstrapPanel() throws { + let envelope = try Self.envelope([ + "engagementPanels": [ + Self.eligiblePanel(chips: [ + ("Summarize the video", "fixture-summary-primary"), + ]), + Self.eligiblePanel(chips: [ + ("Recommend related content", "fixture-related-mirror"), + ]), + ], + ]) + + expectYouTubeAskError(.malformedChip) { + _ = try YouTubeAskParser.parseBootstrap(from: envelope) + } + } + + @Test("Rejects a missing free-text command across content-equivalent panels") + func rejectsMissingFreeTextCommandAcrossMirrors() throws { + let envelope = try Self.envelope([ + "engagementPanels": [ + Self.eligiblePanel( + chips: [("Summarize the video", "fixture-summary-primary")], + freeTextCommand: Self.freeTextCommand( + continuation: "fixture-free-text-command" + ) + ), + Self.eligiblePanel(chips: [ + ("Summarize the video", "fixture-summary-mirror"), + ]), + ], + ]) + + expectYouTubeAskError(.ambiguousBootstrap) { + _ = try YouTubeAskParser.parseBootstrap(from: envelope) + } + } + + @Test("Rejects distinct free-text commands across content-equivalent panels") + func rejectsDistinctFreeTextCommandsAcrossMirrors() throws { + let envelope = try Self.envelope([ + "engagementPanels": [ + Self.eligiblePanel( + chips: [("Summarize the video", "fixture-summary-primary")], + freeTextCommand: Self.freeTextCommand( + continuation: "fixture-free-text-primary" + ) + ), + Self.eligiblePanel( + chips: [("Summarize the video", "fixture-summary-mirror")], + freeTextCommand: Self.freeTextCommand( + continuation: "fixture-free-text-mirror" + ) + ), + ], + ]) + + expectYouTubeAskError(.ambiguousBootstrap) { + _ = try YouTubeAskParser.parseBootstrap(from: envelope) + } + } + + @Test("Free-text capability survives ambiguous mirrored panel continuations") + func freeTextSurvivesAmbiguousPanelContinuations() throws { + let freeTextCommand = Self.freeTextCommand( + continuation: "fixture-free-text-command" + ) + let envelope = try Self.envelope([ + "engagementPanels": [ + [ + "panelIdentifier": "PAyouchat", + "continuationEndpoint": [ + "continuationCommand": [ + "request": "CONTINUATION_REQUEST_TYPE_GET_PANEL", + "token": "fixture-panel-a", + ], + ], + "content": [ + "youChatItemViewModel": [ + "sendUserQueryCommand": freeTextCommand, + ], + ], + ], + [ + "panelIdentifier": "PAyouchat", + "continuationEndpoint": [ + "continuationCommand": [ + "request": "CONTINUATION_REQUEST_TYPE_GET_PANEL", + "token": "fixture-panel-b", + ], + ], + "content": [ + "youChatItemViewModel": [ + "sendUserQueryCommand": freeTextCommand, + ], + ], + ], + ], + ]) + + let parsedBootstrap = try YouTubeAskParser.parseBootstrap(from: envelope) + let bootstrap = try #require(parsedBootstrap) + #expect(bootstrap.panelCommand == nil) + #expect(bootstrap.freeTextCommand != nil) + #expect(bootstrap.suggestions.isEmpty) + } +} diff --git a/Tests/YouTubeAskCoreTests/YouTubeAskParserTests.swift b/Tests/YouTubeAskCoreTests/YouTubeAskParserTests.swift index 30fa427a3..23aacf34f 100644 --- a/Tests/YouTubeAskCoreTests/YouTubeAskParserTests.swift +++ b/Tests/YouTubeAskCoreTests/YouTubeAskParserTests.swift @@ -20,6 +20,11 @@ struct YouTubeAskParserTests { "fixture-chip-continuation-b", ]) #expect(bootstrap.panelCommand?.continuation != "fixture-query-continuation") + #expect(bootstrap.freeTextCommand?.continuation == "fixture-free-text-continuation") + #expect( + bootstrap.freeTextCommand?.clickTrackingParams + == "fixture-free-text-click-tracking" + ) } @Test("Rejects unrelated AI panels and decoy YouChat-shaped content") @@ -159,22 +164,27 @@ struct YouTubeAskParserTests { #expect(conversation.suggestions.first?.command.continuation == "fixture-accepted-continuation") } - @Test("Preserves duplicate chips and localized server order") - func preservesDuplicateChips() throws { + @Test("Deduplicates exact chips while preserving first occurrence and order") + func deduplicatesExactChips() throws { let conversation = try YouTubeAskParser.parseConversation( from: YouTubeAskTestFixture.envelope("YouTubeAskInitialPanel") ) - #expect(conversation.suggestions.map(\.label) == [ - "Ask a follow-up", - "Ask a follow-up", - ]) + #expect(conversation.suggestions.map(\.label) == ["Ask a follow-up"]) #expect(conversation.suggestions.map(\.command.continuation) == [ "fixture-follow-up-continuation", - "fixture-follow-up-continuation", ]) } + @Test("Rejects the same sanitized chip label with a different continuation") + func rejectsAmbiguousChipLabel() { + expectYouTubeAskError(.malformedChip) { + _ = try YouTubeAskParser.parseConversation( + from: YouTubeAskTestFixture.envelope("YouTubeAskConversation") + ) + } + } + @Test("Accepts the observed local list-mutation callback without executing it") func acceptsObservedOnClickListMutation() throws { let envelope = try Self.envelope([ @@ -307,25 +317,23 @@ struct YouTubeAskParserTests { } } - @Test("Preserves duplicate assistant messages and follow-up order") - func preservesConversationOrder() throws { - let conversation = try YouTubeAskParser.parseConversation( - from: YouTubeAskTestFixture.envelope("YouTubeAskConversation") - ) + @Test("Preserves duplicate assistant messages") + func preservesDuplicateAssistantMessages() throws { + let duplicateMessage = [ + "youChatTextMessageViewModel": [ + "text": ["content": "First assistant message"], + ], + ] + let conversation = try YouTubeAskParser.parseConversation(from: Self.conversationEnvelope(items: [ + duplicateMessage, + duplicateMessage, + ])) #expect(conversation.messages.map(\.text) == [ "First assistant message", "First assistant message", - "Second assistant message", - ]) - #expect(conversation.suggestions.map(\.label) == [ - "Continue with details", - "Continue with details", - ]) - #expect(conversation.suggestions.map(\.command.continuation) == [ - "fixture-conversation-continuation-a", - "fixture-conversation-continuation-b", ]) + #expect(conversation.suggestions.isEmpty) } @Test("Accepts messages only from confirmed YouChat response containers") @@ -678,6 +686,44 @@ struct YouTubeAskParserTests { ] } + static func eligiblePanel( + chips: [(label: String, continuation: String)], + freeTextCommand: [String: Any]? = nil + ) -> [String: Any] { + var viewModel: [String: Any] = [ + "chipsData": [ + "chipData": chips.map { chip in + [ + "text": ["simpleText": chip.label], + "continuation": chip.continuation, + ] + }, + ], + ] + if let freeTextCommand { + viewModel["sendUserQueryCommand"] = freeTextCommand + } + return [ + "panelIdentifier": "PAyouchat", + "youChatItemViewModel": viewModel, + ] + } + + static func freeTextCommand( + continuation: String, + clickTrackingParams: String = "fixture-free-text-click" + ) -> [String: Any] { + [ + "innertubeCommand": [ + "clickTrackingParams": clickTrackingParams, + "continuationCommand": [ + "request": "CONTINUATION_REQUEST_TYPE_GET_PANEL", + "token": continuation, + ], + ], + ] + } + private static func conversationEnvelope( chips: [[String: Any]] ) throws -> YouTubeAskWireEnvelope { @@ -702,7 +748,7 @@ struct YouTubeAskParserTests { ]) } - private static func envelope( + static func envelope( _ object: [String: Any] ) throws -> YouTubeAskWireEnvelope { let data = try JSONSerialization.data(withJSONObject: object) diff --git a/Tests/YouTubeAskCoreTests/YouTubeAskRequestBuilderTests.swift b/Tests/YouTubeAskCoreTests/YouTubeAskRequestBuilderTests.swift index 88b32a7ce..b620d9a6b 100644 --- a/Tests/YouTubeAskCoreTests/YouTubeAskRequestBuilderTests.swift +++ b/Tests/YouTubeAskCoreTests/YouTubeAskRequestBuilderTests.swift @@ -55,6 +55,81 @@ struct YouTubeAskRequestBuilderTests { #expect(!bodyText.contains(suggestion.label)) } + @Test("Builds the validated one-shot free-text body and click-tracking context") + func exactFreeTextBody() throws { + let envelope = try YouTubeAskTestFixture.envelope("YouTubeAskEligibleNext") + let parsedBootstrap = try YouTubeAskParser.parseBootstrap(from: envelope) + let command = try #require(parsedBootstrap?.freeTextCommand) + + let data = try YouTubeAskRequestBuilder.makeFreeTextBody( + command: command, + clientMessageID: "youchat-1000", + userInputText: " What is this video about? ", + playerOffsetMilliseconds: 234_000 + ) + let body = try YouTubeAskTestFixture.object(from: data) + + #expect(Set(body.keys) == ["continuation", "formData"]) + #expect(body["continuation"] as? String == "fixture-free-text-continuation") + let formData = try #require(body["formData"] as? [String: Any]) + #expect(Set(formData.keys) == ["inputComposerFormData"]) + let composer = try #require(formData["inputComposerFormData"] as? [String: Any]) + #expect(Set(composer.keys) == ["clientMessageId", "playerOffsetMs", "userInputText"]) + #expect(composer["clientMessageId"] as? String == "youchat-1000") + #expect(composer["playerOffsetMs"] as? String == "234000") + #expect(composer["userInputText"] as? String == "What is this video about?") + + let contextData = try YouTubeAskRequestBuilder.makeFreeTextClickTrackingContext( + command: command + ) + let context = try YouTubeAskTestFixture.object(from: contextData) + #expect(Set(context.keys) == ["clickTracking"]) + let clickTracking = try #require(context["clickTracking"] as? [String: Any]) + #expect(Set(clickTracking.keys) == ["clickTrackingParams"]) + #expect(clickTracking["clickTrackingParams"] as? String == "fixture-free-text-click-tracking") + } + + @Test("Rejects empty and oversized free-text input") + func freeTextValidation() throws { + let envelope = try YouTubeAskTestFixture.envelope("YouTubeAskEligibleNext") + let parsedBootstrap = try YouTubeAskParser.parseBootstrap(from: envelope) + let command = try #require(parsedBootstrap?.freeTextCommand) + let oversizedUTF8 = "a" + String( + repeating: "\u{0301}", + count: YouTubeAskLimits.maximumUserInputBytes + ) + #expect(oversizedUTF8.count <= YouTubeAskLimits.maximumUserInputCharacters) + #expect(oversizedUTF8.utf8.count > YouTubeAskLimits.maximumUserInputBytes) + + for invalid in [ + "", + " ", + String(repeating: "a", count: YouTubeAskLimits.maximumUserInputCharacters + 1), + oversizedUTF8, + ] { + expectYouTubeAskError(.invalidUserInput) { + _ = try YouTubeAskRequestBuilder.makeFreeTextBody( + command: command, + clientMessageID: "youchat-1000", + userInputText: invalid, + playerOffsetMilliseconds: 0 + ) + } + } + } + + @Test("Enforces the final request-body byte limit") + func finalRequestBodyLimit() throws { + try YouTubeAskRequestBuilder.validateRequestBodySize( + Data(count: YouTubeAskLimits.maximumRequestBodyBytes) + ) + expectYouTubeAskError(.requestTooLarge) { + try YouTubeAskRequestBuilder.validateRequestBodySize( + Data(count: YouTubeAskLimits.maximumRequestBodyBytes + 1) + ) + } + } + @Test("Rejects malformed client message IDs") func clientMessageIDValidation() throws { let conversation = try YouTubeAskParser.parseConversation( diff --git a/docs/adr/0032-youtube-ask-gemini.md b/docs/adr/0032-youtube-ask-gemini.md index 0aeb6d495..6c7861f53 100644 --- a/docs/adr/0032-youtube-ask-gemini.md +++ b/docs/adr/0032-youtube-ask-gemini.md @@ -37,14 +37,16 @@ Wire-level observations and the API Explorer workflow remain documented in the `next` response is shared with normal watch-page parsing. WebViews remain limited to authentication and DRM playback; Kaset does not scrape or drive the Ask Gemini DOM. -2. **Ship a chips-only v1.** An eligible watch page exposes a sparkles action - in the top toolbar. Activating it presents a transient, top-centered glass panel - and may prepare the initial panel, but never submits a suggestion or generates - an answer automatically. Outside click, Escape, or the panel header - dismisses the surface without discarding the current watch-scoped conversation. - Only server-issued - suggestion chips and follow-up chips can be selected. Arbitrary text prompts, - a text composer, and `streaming_panel` are out of scope. +2. **Ship server-commanded one-shot free text plus chips.** An eligible watch + page exposes a sparkles action in the top toolbar. Activating it presents a + compact, top-centered glass panel and may prepare the initial panel, but never + generates an answer automatically. The composer appears only when the + canonical eligible panel supplies the exact validated `sendUserQueryCommand`. + One free-text turn is allowed per fresh chat and uses `get_panel` with the + captured `clientMessageId`, string `playerOffsetMs`, `userInputText`, server + continuation, and click-tracking context. After any submitted turn, follow-up + interaction remains server-chip-only until New Chat. Outside click, Escape, + or the close control dismisses the surface without discarding the conversation. 3. **Scope all conversation state to the current watch and account.** Ask is available only to an eligible signed-in primary account. Hidden state is bound to the video, authentication generation, primary-account scope, local @@ -55,7 +57,8 @@ Wire-level observations and the API Explorer workflow remain documented in the telemetry, or logs. 4. **Treat server commands as opaque capabilities.** Continuations and related command objects have no printable, codable, raw-value, or persistence-facing - interface. Kaset preserves server order, replays only the exact command + interface. This includes free-text continuation and click-tracking material. + Kaset preserves server order, replays only the exact command selected by the user, and never substitutes the visible chip label or an invented conversation field. Only sanitized visible messages and local IDs cross into UI models. Server-provided suggestion labels and answers are @@ -93,9 +96,9 @@ Wire-level observations and the API Explorer workflow remain documented in the - Ask follows Kaset's API-over-WebView boundary and shares one strict parser and safety implementation between the app and API Explorer. -- V1 cannot accept free-form questions and does not reproduce every YouTube Ask - capability. It can only replay suggestions YouTube issued for the current - conversation. +- V1 accepts one server-commanded free-form question per fresh chat and does + not reproduce YouTube's unvalidated multi-turn free-text fields. Subsequent + turns use server-issued suggestions or New Chat. - Conversation continuity intentionally ends at the watch/account lifecycle boundary and at app termination. - Opaque command material is harder to inspect during debugging, but accidental diff --git a/docs/api-discovery.md b/docs/api-discovery.md index d09fe41e3..29b712df9 100644 --- a/docs/api-discovery.md +++ b/docs/api-discovery.md @@ -1409,6 +1409,9 @@ swift run api-explorer ask-video-live-test --confirm-live-ai --follow # Live: two independent watch/panel bootstraps, capped at three swift run api-explorer ask-video-live-test --confirm-live-ai --fresh-chats 2 +# Live: one guarded free-text request from a private prompt source +swift run api-explorer ask-video-free-text-test --confirm-live-ai --prompt-file + # Manual structural probe for object, array, streaming, or opaque responses swift run api-explorer --youtube wire-action '' ``` @@ -1420,6 +1423,57 @@ accepts no arbitrary prompt text. Its generated answer display strips control and bidirectional formatting characters, hides links and high-entropy opaque strings, and is bounded to 16,000 characters per answer. +`ask-video-free-text-test` is a separate, one-shot validation command. It +fetches a fresh authenticated runtime-WEB `next` response and selects the first +complete eligible `PAyouchat` panel, matching the browser's mirrored-panel +behavior without merging opaque commands from responsive duplicates. +The accepted schema is intentionally narrow: + +```text +sendUserQueryCommand +└── innertubeCommand + ├── clickTrackingParams: nonempty string + └── continuationCommand + ├── request: CONTINUATION_REQUEST_TYPE_GET_PANEL + └── opaque continuation token is present +``` + +The command sends one `get_panel` request, without retry, using the exact +server continuation and click-tracking context plus: + +```text +formData.inputComposerFormData.clientMessageId: youchat- +formData.inputComposerFormData.playerOffsetMs: decimal millisecond string +formData.inputComposerFormData.userInputText: private prompt contents +``` + +The runtime WEB `context` is added by the authenticated request transport. +Prompts must come from stdin or a regular file owned by the current user with +exact mode `0600`, no extended ACL, valid UTF-8, and at most +16,000 characters. The command rejects guest, `--authuser`, brand-account, +client-version override, verbose, output, raw-body, follow-up, and multi-chat +options. Responses use the bounded `YouTubeAskCore` decoder and strict confirmed +YouChat parser; only sanitized assistant text and redacted structural metrics are +printed. Raw prompts, commands, responses, and conversation values are never +displayed or saved. + +**Live validation on August 2, 2026:** + +- The first guarded API candidate used `streaming_panel` and returned HTTP 400 + before generation, confirming the earlier transport interpretation was stale. +- A user-approved browser capture then submitted one 25-character prompt. The + frontend posted to `get_panel`, not `streaming_panel`, with top-level + `context`, `continuation`, and `formData` only. +- `inputComposerFormData` contained exactly `clientMessageId`, string + `playerOffsetMs`, and `userInputText`; click tracking was nested in + `context.clickTracking`. +- The response returned HTTP 200 as a JSON object with the confirmed singular + `onResponseReceivedCommand.listMutationCommand` shape. The existing bounded + decoder and strict conversation parser accept that response container. +- No second arbitrary-text turn was sent. Production therefore treats free text + as one-shot per fresh chat; follow-up interaction remains server-chip-only or + starts with New Chat. + **Read-only production-parity matrix (added July 28, 2026):** `ask-video-parity` tests the credential-free profiles defined by @@ -1463,8 +1517,8 @@ arguments must be plain relative API paths. | Transport | Current interpretation | |-----------|------------------------| -| `get_panel` | Panel bootstrap and direct suggestion-chip continuation transport | -| `streaming_panel` | Free-text streaming transport only when a server-issued command explicitly selects this API path; observed responses use a top-level JSON array | +| `get_panel` | Panel bootstrap, direct suggestion chips, and the validated one-shot free-text composer transport | +| `streaming_panel` | Frontend capability remains present, but the August 2 free-text candidate returned HTTP 400; not used by production | | `get_watch` | Combined player/watch bootstrap; observed responses use a top-level JSON array | | `get_answer` | Separate AI answer transport; not used by the verified watch-page suggestion flow | @@ -1521,11 +1575,11 @@ shape remains supported for previously validated responses. - A read-only production-client probe then parsed the watch bootstrap successfully. No panel materialization or suggestion submission was performed. -The free-text composer is a different path. It uses the server-issued -`sendUserQueryCommand` (or its own fallback continuation), adds `userInputText`, -and selects `streaming_panel` only when command metadata explicitly names that -endpoint. Arbitrary free-text submission is intentionally not implemented by -`ask-video-live-test`. +The free-text composer uses the exact server-issued `sendUserQueryCommand` +continuation and click tracking. The August 2 browser capture showed that the +frontend posts it to `get_panel` with `clientMessageId`, decimal-string +`playerOffsetMs`, and `userInputText`. Production permits that exact shape once +per fresh chat; unvalidated multi-turn fields remain unsupported. **Live validation on July 27, 2026**: @@ -1584,6 +1638,7 @@ The `--brand` flag sets `context.user.onBehalfOfUser` in the request body. See [ | `ask-video-audit ` | Run a redacted, read-only Ask Gemini / YouChat audit without sending a prompt | | `ask-video-parity ` | Test ordered read-only Ask request profiles using only `next` and initial `get_panel`; never submits a chip | | `ask-video-live-test ` | With `--confirm-live-ai`, replay the server-issued summary chip; optionally add `--follow-up` or `--fresh-chats N` | +| `ask-video-free-text-test ` | With `--confirm-live-ai` and `--prompt-file`, validate one exact server-commanded `get_panel` free-text request with no retry | | `search-audit ` | Audit live Music search shapes, filter chips, continuations, and parser coverage | | `continuation [ep]` | Explore a continuation (`browse`, `search`, or `next`); use the same auth mode as the originating request (`--guest` for guest search) | | `list` | List all known endpoints | @@ -1602,7 +1657,8 @@ The `--brand` flag sets `context.user.onBehalfOfUser` in the request body. See [ | `--brand ` | Use brand account (21-digit ID) | | `--client-version ` | Override the resolved InnerTube client version for compatibility probes | | `--body-file ` | Read a sensitive JSON action body from a mode-0600 regular file or stdin; required for panel/answer transports | -| `--confirm-live-ai` | Required explicit acknowledgement before `ask-video-live-test` sends live AI requests | +| `--prompt-file ` | Read the free-text validation prompt from an exact mode-0600 regular file or stdin; accepted only by `ask-video-free-text-test` | +| `--confirm-live-ai` | Required explicit acknowledgement before either guarded Ask live-test command sends an AI request | | `--follow-up` | Replay the first follow-up chip returned by the live summary response | | `--fresh-chats N` | Run 1-3 independent summary bootstraps (default: 1) | | `--youtube`, `--yt` | Target regular YouTube (`www.youtube.com`, WEB client) instead of YouTube Music | @@ -1624,6 +1680,7 @@ The `--brand` flag sets `context.user.onBehalfOfUser` in the request body. See [ | Date | Changes | |------|---------| +| 2026-08-02 | Browser-validated the one-shot `get_panel` free-text request and response shape; added the guarded `ask-video-free-text-test`; selected the first content-equivalent mirrored YouChat panel; deduplicated repeated visible suggestions; retained one-shot free text plus server-chip follow-ups | | 2026-08-01 | Revalidated an eligible signed-in production watch response; added strict support for the observed local user-turn/loading `onClick` mutation, preserved direct chips while discarding ambiguous panel-only commands, and added one bounded read-only retry for internal identity-fence cancellation | | 2026-07-30 | Enabled the fixed WEB Ask request profile in the production app by explicit product direction; eligibility and all strict parser, identity, and transport gates remain enforced | | 2026-07-28 | Added redacted read-only `ask-video-parity` tooling backed by `YouTubeAskCore`; all three profiles returned HTTP 200 `next` responses but the exported session was treated as signed out, so no profile passed and production remains disabled | diff --git a/docs/architecture.md b/docs/architecture.md index 69754887d..8c5751e3b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -72,7 +72,7 @@ YouTube view models follow the same pattern with `YouTubeClientProtocol` and liv ### YouTube Ask State Boundary -Ask Gemini uses a route-owned, memory-only state machine. Visible messages and local suggestion IDs are separated from opaque server commands. Hidden state is bound to the video ID, authentication generation, confirmed primary-account scope, local conversation ID, and revision. Navigation away, source/account/authentication changes, cancellation, or view-model destruction invalidates the operation and discards both visible and opaque conversation state. Nothing is written to `APICache`, UserDefaults, Keychain, navigation restoration, telemetry, or logs. See [ADR-0032](adr/0032-youtube-ask-gemini.md). +Ask Gemini uses a route-owned, memory-only state machine. Visible messages and local suggestion IDs are separated from opaque server commands. The canonical eligible panel may also supply one opaque, one-shot free-text command; its continuation and click tracking are consumed with the first submitted turn and never exposed to the UI. Hidden state is bound to the video ID, authentication generation, confirmed primary-account scope, local conversation ID, and revision. Navigation away, source/account/authentication changes, cancellation, or view-model destruction invalidates the operation and discards both visible and opaque conversation state. Nothing is written to `APICache`, UserDefaults, Keychain, navigation restoration, telemetry, or logs. See [ADR-0032](adr/0032-youtube-ask-gemini.md). ## Key Services diff --git a/docs/testing.md b/docs/testing.md index 77055e587..ea8aa4880 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -455,8 +455,10 @@ Button { YouTube Ask unit tests are deterministic and offline. The API Explorer parity workflow is a separate, read-only manual compatibility check: it may send `next` and prepare the initial panel, but it must never submit a suggestion or -free-form prompt. Live answer generation requires separate explicit approval and -is not part of routine tests or CI. +free-form prompt. Guarded chip or free-text generation requires separate explicit +approval and is not part of routine tests or CI. Free-text tests assert the exact +validated `get_panel` body, string playback offset, click-tracking context, +one-shot consumption, and no automatic retry. The production app explicitly selects the fixed WEB request profile. The July 28, 2026 parity run was inconclusive because the exported session appeared signed out; diff --git a/docs/youtube.md b/docs/youtube.md index e652cbe41..7402a523c 100644 --- a/docs/youtube.md +++ b/docs/youtube.md @@ -113,11 +113,12 @@ Kaset accepts text and follow-up chips only from the confirmed inserted `youChatItemViewModel` contents, while generated result/link objects remain non-interactive and undisplayed. -The Ask implementation is intentionally chips-only: opening the watch-page -toolbar panel may prepare an initial panel, but it never generates an answer -until the user selects a server-issued suggestion. Follow-up chips are also -server-issued; -there is no free-text composer or `streaming_panel` path. Visible labels and +Opening the watch-page toolbar panel may prepare an initial panel, but it +never generates an answer until the user selects a server-issued suggestion or +submits one free-text prompt. Free text is exposed only when the canonical +eligible panel supplies the exact validated `sendUserQueryCommand`; it uses +`get_panel`, not `streaming_panel`, and is one-shot until New Chat. Follow-up +chips remain server-issued. Visible labels and answers are sanitized but not localized by Kaset. Assistant messages render native Markdown blocks and inline emphasis; link destinations are stripped and never become interactive. @@ -245,9 +246,11 @@ When enabled by a validated request profile and an eligible watch response, Ask Gemini appears as a sparkles action in the top toolbar. Activating it presents a top-centered floating glass panel while leaving Related in place. The panel discloses that YouTube generates the responses, prepares lazily, shows a -height-bounded selectable transcript, disables all chips during a single -in-flight request, and offers New Chat after the first turn or when a submission -outcome is uncertain. Outside click, Escape, or the panel header dismisses the +height-bounded selectable transcript, disables all interactions during a +single in-flight request, and offers New Chat after the first turn or when a +submission outcome is uncertain. The input row matches the Music command bar +and is enabled only for the validated first free-text turn. Outside click, +Escape, or the close control dismisses the panel without discarding its current watch-scoped conversation. The conversation is owned by the current watch view—not `YouTubeViewModelStore`—and is discarded on navigation, source/account/authentication changes, cancellation, @@ -288,9 +291,10 @@ the native scrubber is disabled; YouTube Premium accounts see no ads. YouChat bootstrap for the signed-in primary account and current video. See [ADR-0032](adr/0032-youtube-ask-gemini.md) and the [API discovery record](api-discovery.md#youtube-ask-gemini--youchat-investigation-2026-07-27). -- Ask Gemini v1 intentionally omits free-form prompts, `streaming_panel`, brand - accounts, persisted conversations, telemetry, clickable generated links, and - Apple Intelligence dependencies. +- Ask Gemini v1 intentionally limits free text to one validated `get_panel` + turn per fresh chat. It omits unvalidated multi-turn composer fields, + `streaming_panel`, brand accounts, persisted conversations, telemetry, + clickable generated links, and Apple Intelligence dependencies. - No auto-advance to the next related video after `VIDEO_ENDED` (YouTube autonav is disabled; Kaset shows the ended state — the bar's next button advances manually). From 9877c66598fc43016e72918687e8955c9f9e8ee1 Mon Sep 17 00:00:00 2001 From: Sertac Ozercan Date: Sun, 2 Aug 2026 13:07:00 -0700 Subject: [PATCH 11/18] fix(youtube): keep Ask panel compact Signed-off-by: Sertac Ozercan --- .../Views/YouTube/YouTubeAskPanelView.swift | 16 ++-- .../YouTubeAskPanelLayoutTests.swift | 73 +++++++++++++++++++ 2 files changed, 80 insertions(+), 9 deletions(-) create mode 100644 Tests/KasetTests/YouTubeAskPanelLayoutTests.swift diff --git a/Sources/Kaset/Views/YouTube/YouTubeAskPanelView.swift b/Sources/Kaset/Views/YouTube/YouTubeAskPanelView.swift index acd999a91..77edc49a3 100644 --- a/Sources/Kaset/Views/YouTube/YouTubeAskPanelView.swift +++ b/Sources/Kaset/Views/YouTube/YouTubeAskPanelView.swift @@ -157,6 +157,8 @@ private final class YouTubeAskEscapeMonitorView: NSView { /// messages and server-issued suggestions in the same compact shell as the /// music command bar. struct YouTubeAskPanelView: View { + private static let headerReservedHeight: CGFloat = 58 + private enum FocusTarget: Hashable { case input case close @@ -197,17 +199,13 @@ struct YouTubeAskPanelView: View { .opacity(0.3) ScrollViewReader { proxy in - ViewThatFits(in: .vertical) { + ScrollView(.vertical) { self.panelContent - - ScrollView(.vertical) { - self.panelContent - .padding(.trailing, 4) - } - .frame(maxHeight: self.contentMaximumHeight) - .scrollBounceBehavior(.basedOnSize) + .padding(.trailing, 4) } .frame(maxHeight: self.contentMaximumHeight) + .fixedSize(horizontal: false, vertical: true) + .scrollBounceBehavior(.basedOnSize) .task(id: self.preferredScrollTarget) { await Task.yield() guard !Task.isCancelled, @@ -255,7 +253,7 @@ struct YouTubeAskPanelView: View { } private var contentMaximumHeight: CGFloat { - max(0, min(520, self.maximumHeight - 58)) + max(0, min(520, self.maximumHeight - Self.headerReservedHeight)) } private var preferredFocusTarget: FocusTarget? { diff --git a/Tests/KasetTests/YouTubeAskPanelLayoutTests.swift b/Tests/KasetTests/YouTubeAskPanelLayoutTests.swift new file mode 100644 index 000000000..ef2060a06 --- /dev/null +++ b/Tests/KasetTests/YouTubeAskPanelLayoutTests.swift @@ -0,0 +1,73 @@ +import SwiftUI +import Testing +@testable import Kaset + +@Suite("YouTube Ask panel layout", .serialized) +@MainActor +struct YouTubeAskPanelLayoutTests { + @Test("Short chip content remains compact like the Music command bar") + func shortChipContentUsesIntrinsicHeight() async throws { + let client = MockYouTubeClient() + client.askConversation = YouTubeAskConversation.testing(suggestions: [ + "Summarize the video", + "Recommend related content", + ]) + let viewModel = YouTubeAskViewModel(videoID: "fixture-video", client: client) + viewModel.seed(YouTubeAskBootstrap.testing(suggestions: [ + "Summarize the video", + "Recommend related content", + ])) + viewModel.setExpanded(true) + + for _ in 0 ..< 100 where viewModel.activity != .idle || viewModel.suggestions.isEmpty { + await Task.yield() + } + #expect(viewModel.activity == .idle) + #expect(viewModel.suggestions.count == 2) + + let renderer = ImageRenderer(content: YouTubeAskPanelView( + viewModel: viewModel, + maximumHeight: 700, + playerOffsetMilliseconds: 0 + )) + renderer.scale = 1 + renderer.proposedSize = ProposedViewSize(width: 500, height: 700) + + let image = try #require(renderer.nsImage) + #expect(image.size.height < 300) + } + + @Test("Long replies use the bounded scrolling height") + func longReplyUsesBoundedHeight() async throws { + let client = MockYouTubeClient() + let longReply = Array(repeating: "A detailed generated response that remains selectable.", count: 80) + .joined(separator: "\n\n") + client.askConversation = YouTubeAskConversation.testing( + messages: [ + YouTubeAskMessage(role: .user, text: "Summarize the video"), + YouTubeAskMessage(role: .assistant, text: longReply), + ], + suggestions: ["Ask a follow-up"] + ) + let viewModel = YouTubeAskViewModel(videoID: "fixture-video", client: client) + viewModel.seed(YouTubeAskBootstrap.testing(suggestions: ["Summarize the video"])) + viewModel.setExpanded(true) + + for _ in 0 ..< 100 where viewModel.activity != .idle || viewModel.messages.isEmpty { + await Task.yield() + } + #expect(viewModel.messages.count == 2) + + let renderer = ImageRenderer(content: YouTubeAskPanelView( + viewModel: viewModel, + maximumHeight: 700, + playerOffsetMilliseconds: 0 + )) + renderer.scale = 1 + renderer.proposedSize = ProposedViewSize(width: 500, height: 700) + + let image = try #require(renderer.nsImage) + #expect(image.size.height > 300) + #expect(image.size.height <= 580) + } +} From ac2ae81a0f2596c94c9b13a7cdcae399a3dc23ef Mon Sep 17 00:00:00 2001 From: Sertac Ozercan Date: Sun, 2 Aug 2026 14:48:55 -0700 Subject: [PATCH 12/18] fix(youtube): materialize Ask composer Signed-off-by: Sertac Ozercan --- CONTEXT.md | 4 +- Sources/APIExplorer/AskVideoAudit.swift | 152 ++++++++++++-- .../Models/YouTube/YouTubeAskModels.swift | 33 ++- Sources/Kaset/Resources/Localizable.xcstrings | 190 ++++++++++++++++++ .../Resources/ar.lproj/Localizable.strings | 2 + .../Resources/de.lproj/Localizable.strings | 2 + .../Resources/en.lproj/Localizable.strings | 2 + .../Resources/es.lproj/Localizable.strings | 2 + .../Resources/fr.lproj/Localizable.strings | 2 + .../Resources/id.lproj/Localizable.strings | 2 + .../Resources/it.lproj/Localizable.strings | 2 + .../Resources/ko.lproj/Localizable.strings | 2 + .../Resources/nl.lproj/Localizable.strings | 2 + .../Resources/pl.lproj/Localizable.strings | 2 + .../Resources/pt.lproj/Localizable.strings | 2 + .../Resources/ru.lproj/Localizable.strings | 2 + .../Resources/sv.lproj/Localizable.strings | 2 + .../Resources/tr.lproj/Localizable.strings | 2 + .../Resources/uk.lproj/Localizable.strings | 2 + .../Services/API/YouTubeClient+Ask.swift | 9 +- .../Views/YouTube/YouTubeAskPanelView.swift | 164 ++++++++++----- .../YouTubeAskOpaqueCommand.swift | 2 +- .../YouTubeAskParsedModels.swift | 1 + Sources/YouTubeAskCore/YouTubeAskParser.swift | 8 +- Tests/KasetTests/AppLocalizationTests.swift | 2 + ...YouTubeAskClientMaterializationTests.swift | 182 +++++++++++++++++ .../YouTubeAskPanelLayoutTests.swift | 37 ++++ .../YouTubeAskFixtureSafetyTests.swift | 49 +++++ .../YouTubeAskPanelCapabilityTests.swift | 158 +++++++++++++++ docs/adr/0032-youtube-ask-gemini.md | 21 +- docs/api-discovery.md | 61 ++++-- docs/architecture.md | 2 +- docs/testing.md | 15 +- docs/youtube.md | 10 +- 34 files changed, 1016 insertions(+), 114 deletions(-) create mode 100644 Tests/KasetTests/YouTubeAskClientMaterializationTests.swift create mode 100644 Tests/YouTubeAskCoreTests/YouTubeAskPanelCapabilityTests.swift diff --git a/CONTEXT.md b/CONTEXT.md index cea38bb2f..29da30fb3 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -58,7 +58,7 @@ The watch-page Ask Gemini capability in the regular YouTube experience. Kaset su ## Ask Bootstrap -The eligible YouChat material discovered in the current video's watch-page `next` response. A bootstrap may expose suggestions directly or carry the opaque command needed to prepare the initial Ask panel. It is valid only for the video, authentication generation, and primary-account scope that produced it. +The eligible YouChat material discovered in the current video's watch-page `next` response. A bootstrap may expose suggestions and the one-shot free-text capability directly, or carry the opaque command needed to prepare the prompt-free initial Ask panel. Initial panel materialization may supply a missing free-text capability. The bootstrap and anything materialized from it are valid only for the video, authentication generation, and primary-account scope that produced them. ## Ask Conversation @@ -71,7 +71,7 @@ A sanitized visible chip label paired internally with an opaque server command. ## Server-Issued Ask Free-Text Command -The opaque `sendUserQueryCommand` found in the canonical eligible YouChat panel. Kaset may use it once per fresh chat with the validated `get_panel` form fields and current playback offset. Its continuation and click-tracking values remain in memory and are never printed or persisted. +The opaque `sendUserQueryCommand` found either in the canonical eligible watch-page `next` panel or in its prompt-free initial `get_panel` materialization. Kaset uses the `next` command when present; otherwise it may materialize the exact server-issued panel continuation and accept only a confirmed command from that response. The command may be used once per fresh chat with the validated `get_panel` form fields and current playback offset. Its continuation and click-tracking values remain in memory and are never printed or persisted. ## Opaque Ask Command diff --git a/Sources/APIExplorer/AskVideoAudit.swift b/Sources/APIExplorer/AskVideoAudit.swift index 9b5cb6ab5..3af95a056 100644 --- a/Sources/APIExplorer/AskVideoAudit.swift +++ b/Sources/APIExplorer/AskVideoAudit.swift @@ -180,6 +180,14 @@ private struct AskParityStageMetrics { } } +// MARK: - AskParityCapabilityState + +private enum AskParityCapabilityState: String { + case notRun = "not-run" + case absent + case present +} + // MARK: - AskParityReport private struct AskParityReport { @@ -189,6 +197,8 @@ private struct AskParityReport { var eligibility = "unknown" var nextChipCount = 0 var panelChipCount = 0 + var nextFreeTextCapability = AskParityCapabilityState.notRun + var panelFreeTextCapability = AskParityCapabilityState.notRun var failureCategory: AskParityFailureCategory func render() { @@ -198,6 +208,10 @@ private struct AskParityReport { print("format: next=\(self.next.formatDescription) panel=\(self.panel.formatDescription)") print("eligibility: \(self.eligibility)") print("chip-counts: next=\(self.nextChipCount) panel=\(self.panelChipCount)") + print( + "free-text-capability: next=\(self.nextFreeTextCapability.rawValue) " + + "panel=\(self.panelFreeTextCapability.rawValue)" + ) print("failure-category: \(self.failureCategory.rawValue)") } } @@ -545,6 +559,7 @@ private func evaluateAskParityNext( } report.eligibility = "eligible" report.nextChipCount = bootstrap.suggestions.count + report.nextFreeTextCapability = bootstrap.freeTextCommand == nil ? .absent : .present return AskParityNextEvaluation(report: report, bootstrap: bootstrap) } catch { report.failureCategory = .nextParseFailure @@ -621,6 +636,7 @@ private func evaluateAskParityPanel( do { let panelConversation = try YouTubeAskParser.parseConversation(from: panelEnvelope) report.panelChipCount = panelConversation.suggestions.count + report.panelFreeTextCapability = panelConversation.freeTextCommand == nil ? .absent : .present let panelHasSummarySuggestion = panelConversation.suggestions.contains { suggestion in isAskSummaryLabel(suggestion.label) } @@ -1228,6 +1244,10 @@ private enum AskFreeTextValidationError: LocalizedError { case requestSnapshotChanged case nextRequestFailed case nextHTTPFailure(Int) + case nextAuthenticationRejected + case initialPanelRequestFailed + case initialPanelHTTPFailure(Int) + case initialPanelAuthenticationRejected case streamingRequestFailed case streamingHTTPFailure(Int) case responseTooLarge @@ -1238,7 +1258,7 @@ private enum AskFreeTextValidationError: LocalizedError { var errorDescription: String? { switch self { case .commandUnavailable: - "No eligible PAyouchat free-text command was available" + "No eligible PAyouchat free-text command was available from next or initial get_panel" case .malformedCommand: "The eligible PAyouchat free-text command did not match the exact supported schema" case .ambiguousCommand: @@ -1253,14 +1273,22 @@ private enum AskFreeTextValidationError: LocalizedError { "The authenticated watch bootstrap request failed" case let .nextHTTPFailure(statusCode): "The authenticated watch bootstrap returned HTTP \(statusCode)" + case .nextAuthenticationRejected: + "YouTube did not explicitly confirm the watch bootstrap as signed in" + case .initialPanelRequestFailed: + "The prompt-free initial Ask panel request failed" + case let .initialPanelHTTPFailure(statusCode): + "The prompt-free initial Ask panel returned HTTP \(statusCode)" + case .initialPanelAuthenticationRejected: + "YouTube did not explicitly confirm the initial Ask panel as signed in" case .streamingRequestFailed: "The one-shot free-text request failed" case let .streamingHTTPFailure(statusCode): "The one-shot free-text request returned HTTP \(statusCode)" case .responseTooLarge: - "The one-shot free-text response exceeded the safety limit" + "The Ask response exceeded the safety limit" case .responseDecodeFailed: - "The one-shot free-text response could not be decoded safely" + "The Ask response could not be decoded safely" case .responseParseFailed: "The one-shot free-text response did not match a confirmed YouChat response container" case .answerUnavailable: @@ -1269,6 +1297,28 @@ private enum AskFreeTextValidationError: LocalizedError { } } +// MARK: - AskFreeTextCommandSource + +private enum AskFreeTextCommandSource: String { + case watchNext = "next" + case initialPanel = "initial-get-panel" +} + +// MARK: - AskLoadedFreeTextCommand + +private struct AskLoadedFreeTextCommand: CustomStringConvertible, CustomDebugStringConvertible { + let command: YouTubeAskOpaqueCommand + let source: AskFreeTextCommandSource + + var description: String { + "" + } + + var debugDescription: String { + self.description + } +} + // MARK: - AskFreeTextCookieState private struct AskFreeTextCookieState: Equatable, CustomStringConvertible, CustomDebugStringConvertible { @@ -1620,13 +1670,13 @@ private func makeRuntimeAskFreeTextWireRequest( private func loadAskFreeTextCommand( videoID: String, requestSnapshot: AskFreeTextRequestSnapshot -) async throws -> YouTubeAskOpaqueCommand { +) async throws -> AskLoadedFreeTextCommand { guard let nextBody = try? JSONSerialization.data(withJSONObject: ["videoId": videoID]) else { throw AskFreeTextValidationError.requestEncodingFailed } - let response: APIWireResponse + let nextResponse: APIWireResponse do { - response = try await makeRuntimeAskFreeTextWireRequest( + nextResponse = try await makeRuntimeAskFreeTextWireRequest( endpoint: "next", bodyData: nextBody, requestSnapshot: requestSnapshot @@ -1636,29 +1686,89 @@ private func loadAskFreeTextCommand( } catch { throw AskFreeTextValidationError.nextRequestFailed } - guard (200 ... 299).contains(response.statusCode) else { - throw AskFreeTextValidationError.nextHTTPFailure(response.statusCode) + guard (200 ... 299).contains(nextResponse.statusCode) else { + throw AskFreeTextValidationError.nextHTTPFailure(nextResponse.statusCode) } - let envelope: YouTubeAskWireEnvelope + let nextEnvelope: YouTubeAskWireEnvelope do { - envelope = try YouTubeAskWireDecoder.decode(response.data) + nextEnvelope = try YouTubeAskWireDecoder.decode(nextResponse.data) } catch { throw AskFreeTextValidationError.responseDecodeFailed } - let bootstrap: YouTubeAskParsedBootstrap? + guard askParityHasConfirmedSignedInState( + askParityServerLoggedOutState(in: nextEnvelope) + ) else { + throw AskFreeTextValidationError.nextAuthenticationRejected + } + + let bootstrap: YouTubeAskParsedBootstrap + do { + guard let parsed = try YouTubeAskParser.parseBootstrap(from: nextEnvelope) else { + throw AskFreeTextValidationError.commandUnavailable + } + bootstrap = parsed + } catch let error as AskFreeTextValidationError { + throw error + } catch YouTubeAskCoreError.ambiguousBootstrap { + throw AskFreeTextValidationError.ambiguousCommand + } catch { + throw AskFreeTextValidationError.malformedCommand + } + + if let command = bootstrap.freeTextCommand { + return AskLoadedFreeTextCommand(command: command, source: .watchNext) + } + guard let panelCommand = bootstrap.panelCommand else { + throw AskFreeTextValidationError.commandUnavailable + } + + let panelBody = YouTubeAskRequestBuilder.makePanelBootstrapBody(command: panelCommand) + let panelResponse: APIWireResponse + do { + panelResponse = try await makeRuntimeAskFreeTextWireRequest( + endpoint: "get_panel", + bodyData: panelBody, + requestSnapshot: requestSnapshot, + validateBackingStateBeforeSending: true + ) + } catch is ResponseSizeLimitError { + throw AskFreeTextValidationError.responseTooLarge + } catch let error as AskFreeTextValidationError { + throw error + } catch { + throw AskFreeTextValidationError.initialPanelRequestFailed + } + guard (200 ... 299).contains(panelResponse.statusCode) else { + throw AskFreeTextValidationError.initialPanelHTTPFailure(panelResponse.statusCode) + } + + let panelEnvelope: YouTubeAskWireEnvelope do { - bootstrap = try YouTubeAskParser.parseBootstrap(from: envelope) + panelEnvelope = try YouTubeAskWireDecoder.decode(panelResponse.data) + } catch { + throw AskFreeTextValidationError.responseDecodeFailed + } + + guard askParityHasConfirmedSignedInState( + askParityServerLoggedOutState(in: panelEnvelope) + ) else { + throw AskFreeTextValidationError.initialPanelAuthenticationRejected + } + + let panelConversation: YouTubeAskParsedConversation + do { + panelConversation = try YouTubeAskParser.parseConversation(from: panelEnvelope) } catch YouTubeAskCoreError.ambiguousBootstrap { throw AskFreeTextValidationError.ambiguousCommand } catch { throw AskFreeTextValidationError.malformedCommand } - guard let command = bootstrap?.freeTextCommand else { + guard let command = panelConversation.freeTextCommand else { throw AskFreeTextValidationError.commandUnavailable } - return command + return AskLoadedFreeTextCommand(command: command, source: .initialPanel) } private func sendAskFreeTextRequest( @@ -1730,19 +1840,25 @@ func liveTestAskVideoFreeText( print("====================================\n") print("Video ID: validated (value not displayed)") print("Prompt: loaded privately (\(prompt.count) characters; content not displayed)") - print("Safety: one server-commanded get_panel request; no retry or raw output") + print( + "Safety: at most one prompt-free initial get_panel plus one generated get_panel; " + + "no retry or raw output" + ) print("Request profile: runtime WEB configuration validated by the read-only Ask audit") do { let requestSnapshot = try await captureAskFreeTextRequestSnapshot() - let command = try await loadAskFreeTextCommand( + let loadedCommand = try await loadAskFreeTextCommand( videoID: videoID, requestSnapshot: requestSnapshot ) - print("Eligible PAyouchat free-text command: validated (opaque values hidden)") + print( + "Eligible PAyouchat free-text command: validated from " + + "\(loadedCommand.source.rawValue) (opaque values hidden)" + ) let response = try await sendAskFreeTextRequest( - command: command, + command: loadedCommand.command, prompt: prompt, requestSnapshot: requestSnapshot ) diff --git a/Sources/Kaset/Models/YouTube/YouTubeAskModels.swift b/Sources/Kaset/Models/YouTube/YouTubeAskModels.swift index 96e4a6892..f29518aed 100644 --- a/Sources/Kaset/Models/YouTube/YouTubeAskModels.swift +++ b/Sources/Kaset/Models/YouTube/YouTubeAskModels.swift @@ -109,7 +109,8 @@ struct YouTubeAskBootstrap: Sendable { private let binding: YouTubeAskBindingState var requiresPanelMaterialization: Bool { - self.suggestions.isEmpty && self.panelCommand != nil + self.panelCommand != nil + && (self.suggestions.isEmpty || self.freeTextCommand == nil) } fileprivate init( @@ -158,6 +159,10 @@ struct YouTubeAskBootstrap: Sendable { self.freeTextCommand } + fileprivate var initialSuggestionStates: [YouTubeAskSuggestionState] { + self.suggestionStates + } + var hasFreeTextCommand: Bool { self.freeTextCommand != nil } @@ -512,12 +517,32 @@ extension YouTubeAskConversation { static func materialized( from bootstrap: YouTubeAskBootstrap, parsed: YouTubeAskParsedConversation - ) -> YouTubeAskConversation { - YouTubeAskConversation( + ) -> YouTubeAskConversation? { + let bootstrapCommand = bootstrap.freeTextSubmissionCommand + let materializedCommand = parsed.freeTextCommand + if let bootstrapCommand, let materializedCommand, + bootstrapCommand != materializedCommand + { + return nil + } + let resolvedCommand = materializedCommand ?? bootstrapCommand + + if parsed.suggestions.isEmpty { + let assistantMessages = parsed.messages.map { parsedMessage in + YouTubeAskMessage(role: .assistant, text: parsedMessage.text) + } + return YouTubeAskConversation( + messages: assistantMessages, + suggestionStates: bootstrap.initialSuggestionStates, + freeTextCommand: resolvedCommand, + binding: bootstrap.bindingState + ) + } + return YouTubeAskConversation( previousMessages: [], parsed: parsed, binding: bootstrap.bindingState, - freeTextCommand: bootstrap.freeTextSubmissionCommand + freeTextCommand: resolvedCommand ) } diff --git a/Sources/Kaset/Resources/Localizable.xcstrings b/Sources/Kaset/Resources/Localizable.xcstrings index bc870abc1..028f849d5 100644 --- a/Sources/Kaset/Resources/Localizable.xcstrings +++ b/Sources/Kaset/Resources/Localizable.xcstrings @@ -23584,6 +23584,101 @@ } } }, + "Try asking:": { + "comment": "Heading above server-issued suggestions in the YouTube Ask Gemini panel", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "جرّب أن تسأل:" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Frag zum Beispiel:" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Try asking:" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Prueba a preguntar:" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Essayez de poser une question :" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Coba tanyakan:" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Prova a chiedere:" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이렇게 질문해 보세요:" + } + }, + "nl": { + "stringUnit": { + "state": "translated", + "value": "Probeer eens te vragen:" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Spróbuj zapytać:" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Experimente perguntar:" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Попробуйте спросить:" + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Prova att fråga:" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Şunu sormayı deneyin:" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Спробуйте запитати:" + } + } + } + }, "Try commands like:": { "localizations": { "ar": { @@ -48022,6 +48117,101 @@ } } }, + "Ask anything about this video...": { + "comment": "Placeholder for the free-text input in the YouTube Ask Gemini panel", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "اسأل أي شيء عن هذا الفيديو..." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Frag alles über dieses Video…" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Ask anything about this video..." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Pregunta lo que quieras sobre este vídeo…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Posez n'importe quelle question sur cette vidéo..." + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Tanyakan apa saja tentang video ini..." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Chiedi qualsiasi cosa su questo video…" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 동영상에 대해 무엇이든 물어보세요..." + } + }, + "nl": { + "stringUnit": { + "state": "translated", + "value": "Vraag alles over deze video…" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zapytaj o wszystko związane z tym filmem…" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Pergunte qualquer coisa sobre este vídeo…" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Спросите что угодно об этом видео…" + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Fråga vad som helst om den här videon…" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bu video hakkında istediğinizi sorun..." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Запитайте що завгодно про це відео..." + } + } + } + }, "Ask about this video...": { "comment": "Placeholder for the free-text input in the YouTube Ask Gemini panel", "localizations": { diff --git a/Sources/Kaset/Resources/ar.lproj/Localizable.strings b/Sources/Kaset/Resources/ar.lproj/Localizable.strings index ad89dfd87..4ef916566 100644 --- a/Sources/Kaset/Resources/ar.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/ar.lproj/Localizable.strings @@ -547,6 +547,8 @@ // YouTube Ask Gemini "Ask Gemini" = "اسأل Gemini"; "Ask about this video..." = "اسأل عن هذا الفيديو..."; +"Ask anything about this video..." = "اسأل أي شيء عن هذا الفيديو..."; +"Try asking:" = "جرّب أن تسأل:"; "Responses are generated by YouTube and may be inaccurate." = "يتم إنشاء الردود بواسطة YouTube وقد تكون غير دقيقة."; "Collapse Ask Gemini" = "طي «اسأل Gemini»"; "Expand Ask Gemini" = "توسيع «اسأل Gemini»"; diff --git a/Sources/Kaset/Resources/de.lproj/Localizable.strings b/Sources/Kaset/Resources/de.lproj/Localizable.strings index 32a29445d..d5102f100 100644 --- a/Sources/Kaset/Resources/de.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/de.lproj/Localizable.strings @@ -547,6 +547,8 @@ // YouTube Ask Gemini "Ask Gemini" = "Gemini fragen"; "Ask about this video..." = "Frag etwas zu diesem Video…"; +"Ask anything about this video..." = "Frag alles über dieses Video…"; +"Try asking:" = "Frag zum Beispiel:"; "Responses are generated by YouTube and may be inaccurate." = "Die Antworten werden von YouTube generiert und können ungenau sein."; "Collapse Ask Gemini" = "„Gemini fragen“ einklappen"; "Expand Ask Gemini" = "„Gemini fragen“ ausklappen"; diff --git a/Sources/Kaset/Resources/en.lproj/Localizable.strings b/Sources/Kaset/Resources/en.lproj/Localizable.strings index f26611a92..af2f1cbb5 100644 --- a/Sources/Kaset/Resources/en.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/en.lproj/Localizable.strings @@ -18,6 +18,8 @@ // YouTube Ask Gemini "Ask Gemini" = "Ask Gemini"; "Ask about this video..." = "Ask about this video..."; +"Ask anything about this video..." = "Ask anything about this video..."; +"Try asking:" = "Try asking:"; "Responses are generated by YouTube and may be inaccurate." = "Responses are generated by YouTube and may be inaccurate."; "Collapse Ask Gemini" = "Collapse Ask Gemini"; "Expand Ask Gemini" = "Expand Ask Gemini"; diff --git a/Sources/Kaset/Resources/es.lproj/Localizable.strings b/Sources/Kaset/Resources/es.lproj/Localizable.strings index 6c04e6943..1be80e967 100644 --- a/Sources/Kaset/Resources/es.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/es.lproj/Localizable.strings @@ -547,6 +547,8 @@ // YouTube Ask Gemini "Ask Gemini" = "Preguntar a Gemini"; "Ask about this video..." = "Pregunta sobre este vídeo…"; +"Ask anything about this video..." = "Pregunta lo que quieras sobre este vídeo…"; +"Try asking:" = "Prueba a preguntar:"; "Responses are generated by YouTube and may be inaccurate." = "Las respuestas las genera YouTube y pueden contener errores."; "Collapse Ask Gemini" = "Contraer «Preguntar a Gemini»"; "Expand Ask Gemini" = "Expandir «Preguntar a Gemini»"; diff --git a/Sources/Kaset/Resources/fr.lproj/Localizable.strings b/Sources/Kaset/Resources/fr.lproj/Localizable.strings index 4c487a00a..909879db7 100644 --- a/Sources/Kaset/Resources/fr.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/fr.lproj/Localizable.strings @@ -547,6 +547,8 @@ // YouTube Ask Gemini "Ask Gemini" = "Demander à Gemini"; "Ask about this video..." = "Posez une question sur cette vidéo..."; +"Ask anything about this video..." = "Posez n'importe quelle question sur cette vidéo..."; +"Try asking:" = "Essayez de poser une question :"; "Responses are generated by YouTube and may be inaccurate." = "Les réponses sont générées par YouTube et peuvent être inexactes."; "Collapse Ask Gemini" = "Replier « Demander à Gemini »"; "Expand Ask Gemini" = "Déplier « Demander à Gemini »"; diff --git a/Sources/Kaset/Resources/id.lproj/Localizable.strings b/Sources/Kaset/Resources/id.lproj/Localizable.strings index a2e036e50..04afe69ae 100644 --- a/Sources/Kaset/Resources/id.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/id.lproj/Localizable.strings @@ -547,6 +547,8 @@ // YouTube Ask Gemini "Ask Gemini" = "Tanya Gemini"; "Ask about this video..." = "Tanyakan tentang video ini..."; +"Ask anything about this video..." = "Tanyakan apa saja tentang video ini..."; +"Try asking:" = "Coba tanyakan:"; "Responses are generated by YouTube and may be inaccurate." = "Respons dibuat oleh YouTube dan mungkin tidak akurat."; "Collapse Ask Gemini" = "Ciutkan Tanya Gemini"; "Expand Ask Gemini" = "Luaskan Tanya Gemini"; diff --git a/Sources/Kaset/Resources/it.lproj/Localizable.strings b/Sources/Kaset/Resources/it.lproj/Localizable.strings index 8a80e6807..bfcbd3f4d 100644 --- a/Sources/Kaset/Resources/it.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/it.lproj/Localizable.strings @@ -547,6 +547,8 @@ // YouTube Ask Gemini "Ask Gemini" = "Chiedi a Gemini"; "Ask about this video..." = "Chiedi informazioni su questo video…"; +"Ask anything about this video..." = "Chiedi qualsiasi cosa su questo video…"; +"Try asking:" = "Prova a chiedere:"; "Responses are generated by YouTube and may be inaccurate." = "Le risposte sono generate da YouTube e potrebbero essere inesatte."; "Collapse Ask Gemini" = "Comprimi «Chiedi a Gemini»"; "Expand Ask Gemini" = "Espandi «Chiedi a Gemini»"; diff --git a/Sources/Kaset/Resources/ko.lproj/Localizable.strings b/Sources/Kaset/Resources/ko.lproj/Localizable.strings index 4a87888bf..5e1e4e22b 100644 --- a/Sources/Kaset/Resources/ko.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/ko.lproj/Localizable.strings @@ -547,6 +547,8 @@ // YouTube Ask Gemini "Ask Gemini" = "Gemini에게 질문"; "Ask about this video..." = "이 동영상에 대해 질문하세요..."; +"Ask anything about this video..." = "이 동영상에 대해 무엇이든 물어보세요..."; +"Try asking:" = "이렇게 질문해 보세요:"; "Responses are generated by YouTube and may be inaccurate." = "응답은 YouTube에서 생성되며 정확하지 않을 수 있습니다."; "Collapse Ask Gemini" = "Gemini에게 질문 접기"; "Expand Ask Gemini" = "Gemini에게 질문 펼치기"; diff --git a/Sources/Kaset/Resources/nl.lproj/Localizable.strings b/Sources/Kaset/Resources/nl.lproj/Localizable.strings index 1c65491fa..8ad09cf0c 100644 --- a/Sources/Kaset/Resources/nl.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/nl.lproj/Localizable.strings @@ -547,6 +547,8 @@ // YouTube Ask Gemini "Ask Gemini" = "Vraag Gemini"; "Ask about this video..." = "Vraag iets over deze video…"; +"Ask anything about this video..." = "Vraag alles over deze video…"; +"Try asking:" = "Probeer eens te vragen:"; "Responses are generated by YouTube and may be inaccurate." = "Antwoorden worden gegenereerd door YouTube en kunnen onnauwkeurig zijn."; "Collapse Ask Gemini" = "Vraag Gemini inklappen"; "Expand Ask Gemini" = "Vraag Gemini uitklappen"; diff --git a/Sources/Kaset/Resources/pl.lproj/Localizable.strings b/Sources/Kaset/Resources/pl.lproj/Localizable.strings index f4ce695f4..937b19e0d 100644 --- a/Sources/Kaset/Resources/pl.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/pl.lproj/Localizable.strings @@ -547,6 +547,8 @@ // YouTube Ask Gemini "Ask Gemini" = "Zapytaj Gemini"; "Ask about this video..." = "Zapytaj o ten film…"; +"Ask anything about this video..." = "Zapytaj o wszystko związane z tym filmem…"; +"Try asking:" = "Spróbuj zapytać:"; "Responses are generated by YouTube and may be inaccurate." = "Odpowiedzi są generowane przez YouTube i mogą być niedokładne."; "Collapse Ask Gemini" = "Zwiń Zapytaj Gemini"; "Expand Ask Gemini" = "Rozwiń Zapytaj Gemini"; diff --git a/Sources/Kaset/Resources/pt.lproj/Localizable.strings b/Sources/Kaset/Resources/pt.lproj/Localizable.strings index dbc0ce2e7..90a0d5799 100644 --- a/Sources/Kaset/Resources/pt.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/pt.lproj/Localizable.strings @@ -547,6 +547,8 @@ // YouTube Ask Gemini "Ask Gemini" = "Perguntar ao Gemini"; "Ask about this video..." = "Pergunte sobre este vídeo…"; +"Ask anything about this video..." = "Pergunte qualquer coisa sobre este vídeo…"; +"Try asking:" = "Experimente perguntar:"; "Responses are generated by YouTube and may be inaccurate." = "As respostas são geradas pelo YouTube e podem estar incorretas."; "Collapse Ask Gemini" = "Recolher Perguntar ao Gemini"; "Expand Ask Gemini" = "Expandir Perguntar ao Gemini"; diff --git a/Sources/Kaset/Resources/ru.lproj/Localizable.strings b/Sources/Kaset/Resources/ru.lproj/Localizable.strings index b940b0e5d..352ddb5f8 100644 --- a/Sources/Kaset/Resources/ru.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/ru.lproj/Localizable.strings @@ -547,6 +547,8 @@ // YouTube Ask Gemini "Ask Gemini" = "Спросить Gemini"; "Ask about this video..." = "Спросите об этом видео…"; +"Ask anything about this video..." = "Спросите что угодно об этом видео…"; +"Try asking:" = "Попробуйте спросить:"; "Responses are generated by YouTube and may be inaccurate." = "Ответы создаются YouTube и могут быть неточными."; "Collapse Ask Gemini" = "Свернуть «Спросить Gemini»"; "Expand Ask Gemini" = "Развернуть «Спросить Gemini»"; diff --git a/Sources/Kaset/Resources/sv.lproj/Localizable.strings b/Sources/Kaset/Resources/sv.lproj/Localizable.strings index 2586ef8d6..335efec2d 100644 --- a/Sources/Kaset/Resources/sv.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/sv.lproj/Localizable.strings @@ -547,6 +547,8 @@ // YouTube Ask Gemini "Ask Gemini" = "Fråga Gemini"; "Ask about this video..." = "Fråga om den här videon…"; +"Ask anything about this video..." = "Fråga vad som helst om den här videon…"; +"Try asking:" = "Prova att fråga:"; "Responses are generated by YouTube and may be inaccurate." = "Svaren genereras av YouTube och kan vara felaktiga."; "Collapse Ask Gemini" = "Fäll ihop Fråga Gemini"; "Expand Ask Gemini" = "Fäll ut Fråga Gemini"; diff --git a/Sources/Kaset/Resources/tr.lproj/Localizable.strings b/Sources/Kaset/Resources/tr.lproj/Localizable.strings index 8ec93a4af..1740c8ddf 100644 --- a/Sources/Kaset/Resources/tr.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/tr.lproj/Localizable.strings @@ -547,6 +547,8 @@ // YouTube Ask Gemini "Ask Gemini" = "Gemini'ye Sor"; "Ask about this video..." = "Bu video hakkında sorun..."; +"Ask anything about this video..." = "Bu video hakkında istediğinizi sorun..."; +"Try asking:" = "Şunu sormayı deneyin:"; "Responses are generated by YouTube and may be inaccurate." = "Yanıtlar YouTube tarafından oluşturulur ve hatalı olabilir."; "Collapse Ask Gemini" = "Gemini'ye Sor bölümünü daralt"; "Expand Ask Gemini" = "Gemini'ye Sor bölümünü genişlet"; diff --git a/Sources/Kaset/Resources/uk.lproj/Localizable.strings b/Sources/Kaset/Resources/uk.lproj/Localizable.strings index b84a779eb..4a882a241 100644 --- a/Sources/Kaset/Resources/uk.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/uk.lproj/Localizable.strings @@ -547,6 +547,8 @@ // YouTube Ask Gemini "Ask Gemini" = "Запитати Gemini"; "Ask about this video..." = "Запитайте про це відео..."; +"Ask anything about this video..." = "Запитайте що завгодно про це відео..."; +"Try asking:" = "Спробуйте запитати:"; "Responses are generated by YouTube and may be inaccurate." = "Відповіді створює YouTube, і вони можуть бути неточними."; "Collapse Ask Gemini" = "Згорнути «Запитати Gemini»"; "Expand Ask Gemini" = "Розгорнути «Запитати Gemini»"; diff --git a/Sources/Kaset/Services/API/YouTubeClient+Ask.swift b/Sources/Kaset/Services/API/YouTubeClient+Ask.swift index 76569c6b5..93612919d 100644 --- a/Sources/Kaset/Services/API/YouTubeClient+Ask.swift +++ b/Sources/Kaset/Services/API/YouTubeClient+Ask.swift @@ -31,14 +31,19 @@ extension YouTubeClient { snapshot: snapshot ) guard !parsed.suggestions.isEmpty + || !bootstrap.suggestions.isEmpty + || parsed.freeTextCommand != nil || bootstrap.hasFreeTextCommand else { throw YouTubeAskClientError.invalidResponse } - return YouTubeAskConversation.materialized( + guard let conversation = YouTubeAskConversation.materialized( from: bootstrap, parsed: parsed - ) + ) else { + throw YouTubeAskClientError.invalidResponse + } + return conversation } func continueAskConversation( diff --git a/Sources/Kaset/Views/YouTube/YouTubeAskPanelView.swift b/Sources/Kaset/Views/YouTube/YouTubeAskPanelView.swift index 77edc49a3..778da0f6d 100644 --- a/Sources/Kaset/Views/YouTube/YouTubeAskPanelView.swift +++ b/Sources/Kaset/Views/YouTube/YouTubeAskPanelView.swift @@ -185,15 +185,9 @@ struct YouTubeAskPanelView: View { CompatGlassContainer(spacing: 0) { VStack(spacing: 0) { - Group { - if self.viewModel.acceptsFreeTextInput { - self.inputRow(text: $viewModel.inputText) - } else { - self.headerRow - } - } - .padding(.horizontal, 16) - .padding(.vertical, 14) + self.inputRow(text: $viewModel.inputText) + .padding(.horizontal, 16) + .padding(.vertical, 14) Divider() .opacity(0.3) @@ -236,7 +230,7 @@ struct YouTubeAskPanelView: View { } private var panelContent: some View { - VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 10) { Text( "Responses are generated by YouTube and may be inaccurate.", comment: "Disclosure shown in the YouTube Ask Gemini panel" @@ -248,7 +242,7 @@ struct YouTubeAskPanelView: View { self.expandedContent } .padding(.horizontal, 16) - .padding(.vertical, 14) + .padding(.vertical, 16) .frame(maxWidth: .infinity, alignment: .leading) } @@ -257,10 +251,10 @@ struct YouTubeAskPanelView: View { } private var preferredFocusTarget: FocusTarget? { - // Establish an accessible focus destination only while preparing a fresh, - // empty chat. Once a turn is visible, preserve reply scroll position and - // let keyboard focus move naturally instead of forcing it off-screen. - guard self.viewModel.messages.isEmpty else { return nil } + // Establish an accessible focus destination while the chat is fresh. + // A server welcome message is still pre-turn content; only a user turn + // should stop the composer from receiving initial focus. + guard !self.viewModel.hasStarted else { return nil } if self.viewModel.presentationError != nil || self.viewModel.isBusy { return .close } @@ -282,6 +276,12 @@ struct YouTubeAskPanelView: View { if self.viewModel.presentationError != nil || self.viewModel.activity != .idle { return .status } + if !self.viewModel.hasStarted, + !self.viewModel.requiresNewChat, + let suggestionID = self.viewModel.suggestions.first?.id + { + return .suggestion(suggestionID) + } if let messageID = self.viewModel.messages.last?.id { return .message(messageID) } @@ -296,29 +296,13 @@ struct YouTubeAskPanelView: View { return nil } - private var headerRow: some View { - HStack(spacing: 12) { - Image(systemName: "sparkles") - .font(.system(size: 16)) - .foregroundStyle(.tint) - - Text("Ask Gemini") - .font(.system(size: 16, weight: .semibold)) - .foregroundStyle(.primary) - - Spacer(minLength: 0) - - self.closeButton - } - } - private func inputRow(text: Binding) -> some View { HStack(spacing: 12) { Image(systemName: "sparkles") .font(.system(size: 16)) .foregroundStyle(.tint) - TextField(String(localized: "Ask about this video..."), text: text) + TextField(String(localized: "Ask anything about this video..."), text: text) .textFieldStyle(.plain) .font(.system(size: 16)) .focused(self.$focusedControl, equals: .input) @@ -338,19 +322,20 @@ struct YouTubeAskPanelView: View { .frame(width: 11, height: 11) } else if !self.viewModel.inputText.isEmpty { Button { - self.viewModel.submitInput( - playerOffsetMilliseconds: self.playerOffsetMilliseconds - ) + self.viewModel.inputText = "" } label: { - Image(systemName: "paperplane.fill") - .foregroundStyle(.tint) + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) } .buttonStyle(.plain) - .disabled(!self.viewModel.canSubmitInput) - .accessibilityLabel(String(localized: "Send")) - .accessibilityIdentifier(AccessibilityID.YouTubeContent.askSend) + .disabled(self.viewModel.isBusy) + .accessibilityLabel(String(localized: "Clear input")) } + Divider() + .frame(height: 18) + .opacity(0.3) + self.closeButton } } @@ -384,7 +369,13 @@ struct YouTubeAskPanelView: View { } if !self.viewModel.suggestions.isEmpty, !self.viewModel.requiresNewChat { - self.suggestions + VStack(alignment: .leading, spacing: 10) { + Text("Try asking:") + .font(.caption) + .foregroundStyle(.tertiary) + + self.suggestions + } } if self.viewModel.canStartNewChat { @@ -451,11 +442,7 @@ struct YouTubeAskPanelView: View { } private var suggestions: some View { - LazyVGrid( - columns: [GridItem(.adaptive(minimum: 180), spacing: 8)], - alignment: .leading, - spacing: 8 - ) { + YouTubeAskSuggestionFlowLayout(horizontalSpacing: 8, verticalSpacing: 8) { ForEach(Array(self.viewModel.suggestions.enumerated()), id: \.element.id) { index, suggestion in Button { self.viewModel.selectSuggestion(id: suggestion.id) @@ -467,7 +454,6 @@ struct YouTubeAskPanelView: View { .fixedSize(horizontal: false, vertical: true) .padding(.horizontal, 10) .padding(.vertical, 7) - .frame(maxWidth: .infinity, alignment: .leading) .background(.quaternary, in: Capsule()) .contentShape(Capsule()) } @@ -512,6 +498,89 @@ struct YouTubeAskPanelView: View { } } +// MARK: - YouTubeAskSuggestionFlowLayout + +private struct YouTubeAskSuggestionFlowLayout: Layout { + let horizontalSpacing: CGFloat + let verticalSpacing: CGFloat + + func sizeThatFits( + proposal: ProposedViewSize, + subviews: Subviews, + cache _: inout () + ) -> CGSize { + let maximumWidth = proposal.width ?? .greatestFiniteMagnitude + let measured = self.measure(subviews: subviews, maximumWidth: maximumWidth) + return CGSize( + width: proposal.width ?? measured.width, + height: measured.height + ) + } + + func placeSubviews( + in bounds: CGRect, + proposal _: ProposedViewSize, + subviews: Subviews, + cache _: inout () + ) { + var x = bounds.minX + var y = bounds.minY + var rowHeight: CGFloat = 0 + + for subview in subviews { + let size = subview.sizeThatFits(ProposedViewSize( + width: bounds.width, + height: nil + )) + if x > bounds.minX, x + size.width > bounds.maxX { + x = bounds.minX + y += rowHeight + self.verticalSpacing + rowHeight = 0 + } + subview.place( + at: CGPoint(x: x, y: y), + anchor: .topLeading, + proposal: ProposedViewSize(width: size.width, height: size.height) + ) + x += size.width + self.horizontalSpacing + rowHeight = max(rowHeight, size.height) + } + } + + private func measure( + subviews: Subviews, + maximumWidth: CGFloat + ) -> CGSize { + var rowWidth: CGFloat = 0 + var rowHeight: CGFloat = 0 + var measuredWidth: CGFloat = 0 + var measuredHeight: CGFloat = 0 + + for subview in subviews { + let size = subview.sizeThatFits(ProposedViewSize( + width: maximumWidth, + height: nil + )) + let proposedWidth = rowWidth == 0 + ? size.width + : rowWidth + self.horizontalSpacing + size.width + if rowWidth > 0, proposedWidth > maximumWidth { + measuredWidth = max(measuredWidth, rowWidth) + measuredHeight += rowHeight + self.verticalSpacing + rowWidth = size.width + rowHeight = size.height + } else { + rowWidth = proposedWidth + rowHeight = max(rowHeight, size.height) + } + } + + measuredWidth = max(measuredWidth, rowWidth) + measuredHeight += rowHeight + return CGSize(width: measuredWidth, height: measuredHeight) + } +} + // MARK: - YouTube Ask Accessibility Announcements extension View { @@ -586,7 +655,6 @@ extension AccessibilityID.YouTubeContent { static let askOverlay = "youtubeContent.askOverlay" static let askPanel = "youtubeContent.askPanel" static let askInput = "youtubeContent.askInput" - static let askSend = "youtubeContent.askSend" static let askToggle = "youtubeContent.askToggle" static let askTranscript = "youtubeContent.askTranscript" static let askNewChat = "youtubeContent.askNewChat" diff --git a/Sources/YouTubeAskCore/YouTubeAskOpaqueCommand.swift b/Sources/YouTubeAskCore/YouTubeAskOpaqueCommand.swift index f30e5bedb..67f19d8c9 100644 --- a/Sources/YouTubeAskCore/YouTubeAskOpaqueCommand.swift +++ b/Sources/YouTubeAskCore/YouTubeAskOpaqueCommand.swift @@ -4,7 +4,7 @@ import Foundation /// Server-issued command material. The raw value is intentionally inaccessible /// outside this module and has only redacted string/reflection representations. -package struct YouTubeAskOpaqueCommand: Sendable { +package struct YouTubeAskOpaqueCommand: Equatable, Sendable { let continuation: String let clickTrackingParams: String? diff --git a/Sources/YouTubeAskCore/YouTubeAskParsedModels.swift b/Sources/YouTubeAskCore/YouTubeAskParsedModels.swift index 37aa78100..1a21ab476 100644 --- a/Sources/YouTubeAskCore/YouTubeAskParsedModels.swift +++ b/Sources/YouTubeAskCore/YouTubeAskParsedModels.swift @@ -27,4 +27,5 @@ package struct YouTubeAskParsedMessage: Sendable { package struct YouTubeAskParsedConversation: Sendable { package let messages: [YouTubeAskParsedMessage] package let suggestions: [YouTubeAskParsedSuggestion] + package let freeTextCommand: YouTubeAskOpaqueCommand? } diff --git a/Sources/YouTubeAskCore/YouTubeAskParser.swift b/Sources/YouTubeAskCore/YouTubeAskParser.swift index 28d51516a..347e931dc 100644 --- a/Sources/YouTubeAskCore/YouTubeAskParser.swift +++ b/Sources/YouTubeAskCore/YouTubeAskParser.swift @@ -121,15 +121,18 @@ package enum YouTubeAskParser { ) } content.suggestions = try Self.deduplicatedSuggestions(content.suggestions) + let freeTextCommand = try Self.unambiguousFreeTextCommand(content.freeTextCommands) return YouTubeAskParsedConversation( messages: content.messages, - suggestions: content.suggestions + suggestions: content.suggestions, + freeTextCommand: freeTextCommand ) } package struct ConversationAccumulator { var messages: [YouTubeAskParsedMessage] = [] var suggestions: [YouTubeAskParsedSuggestion] = [] + var freeTextCommands: [YouTubeAskOpaqueCommand] = [] } struct TraversalBudget { @@ -458,6 +461,9 @@ package enum YouTubeAskParser { if let chipsData = viewModel["chipsData"] { try content.suggestions.append(contentsOf: Self.parseChipsData(chipsData)) } + if includeMessages, let command = viewModel["sendUserQueryCommand"] { + try content.freeTextCommands.append(Self.parseFreeTextCommand(command)) + } if includeMessages, viewModel["chipsData"] == nil, viewModel["text"] != nil diff --git a/Tests/KasetTests/AppLocalizationTests.swift b/Tests/KasetTests/AppLocalizationTests.swift index 9046e8f87..adf329007 100644 --- a/Tests/KasetTests/AppLocalizationTests.swift +++ b/Tests/KasetTests/AppLocalizationTests.swift @@ -384,7 +384,9 @@ struct AppLocalizationTests { ("ar", "New Chat", "محادثة جديدة"), ("de", "Ask Gemini", "Gemini fragen"), ("de", "Ask about this video...", "Frag etwas zu diesem Video…"), + ("de", "Ask anything about this video...", "Frag alles über dieses Video…"), ("fr", "Send", "Envoyer"), + ("ko", "Try asking:", "이렇게 질문해 보세요:"), ("ko", "YouTube response: %@", "YouTube 응답: %@"), ("tr", "Sending…", "Gönderiliyor…"), ] diff --git a/Tests/KasetTests/YouTubeAskClientMaterializationTests.swift b/Tests/KasetTests/YouTubeAskClientMaterializationTests.swift new file mode 100644 index 000000000..e92cfe176 --- /dev/null +++ b/Tests/KasetTests/YouTubeAskClientMaterializationTests.swift @@ -0,0 +1,182 @@ +import Foundation +import Testing +@testable import Kaset + +extension YouTubeAskClientTests { + @Test("Direct preview chips materialize the missing free-text capability") + @MainActor + func directChipsMaterializeMissingFreeTextCapability() async throws { + let requestCount = LockedCounter() + let session = MockURLProtocol.makeMockSession { request in + switch requestCount.increment() { + case 1: + return Self.response(for: request, data: Self.directChipsPanelNextData) + case 2: + #expect(request.url?.path == "/youtubei/v1/get_panel") + let body = try Self.body(from: request) + #expect(Set(body.keys) == ["context", "continuation"]) + #expect(body["continuation"] as? String == "fixture-panel-continuation") + #expect(body["formData"] == nil) + return Self.response(for: request, data: Self.initialPanelWithFreeTextData) + default: + Issue.record("Initial Ask panel materialization retried unexpectedly") + return Self.response(for: request, data: Data(#"{}"#.utf8)) + } + } + defer { MockURLProtocol.reset(session: session) } + + let (client, _) = try await Self.makeAuthenticatedClient(session: session) + let page = try await client.getWatchPage(videoId: "fixture-video") + let bootstrap = try #require(page.askBootstrap) + #expect(bootstrap.suggestions.map(\.text) == ["Preview summary"]) + + let conversation = try await client.loadAskConversation(from: bootstrap) + + #expect(requestCount.count == 2) + #expect(conversation.canSubmitFreeText) + #expect(conversation.messages.isEmpty) + #expect(conversation.suggestions.map(\.text) == [ + "Summarize the video", + "Recommend related content", + "What is the main claim?", + ]) + } + + @Test("Materialized free text preserves preview chips and welcome messages") + @MainActor + func materializedFreeTextPreservesPreviewContent() async throws { + let requestCount = LockedCounter() + let session = MockURLProtocol.makeMockSession { request in + switch requestCount.increment() { + case 1: + return Self.response(for: request, data: Self.directChipsPanelNextData) + case 2: + return Self.response(for: request, data: Self.initialPanelFreeTextOnlyData) + default: + Issue.record("Initial Ask panel materialization retried unexpectedly") + return Self.response(for: request, data: Data(#"{}"#.utf8)) + } + } + defer { MockURLProtocol.reset(session: session) } + + let (client, _) = try await Self.makeAuthenticatedClient(session: session) + let page = try await client.getWatchPage(videoId: "fixture-video") + let conversation = try await client.loadAskConversation(from: #require(page.askBootstrap)) + + #expect(requestCount.count == 2) + #expect(conversation.canSubmitFreeText) + #expect(!conversation.hasStarted) + #expect(conversation.messages.map(\.text) == ["Ask me anything about this video."]) + #expect(conversation.suggestions.map(\.text) == ["Preview summary"]) + } + + private static let directChipsPanelNextData = Data( + #""" + { + "contents": {}, + "engagementPanels": [ + { + "engagementPanelSectionListRenderer": { + "panelIdentifier": "PAyouchat", + "continuationEndpoint": { + "continuationCommand": { + "request": "CONTINUATION_REQUEST_TYPE_GET_PANEL", + "token": "fixture-panel-continuation" + } + }, + "content": { + "youChatItemViewModel": { + "chipsData": { + "chipData": [ + { + "text": {"simpleText": "Preview summary"}, + "continuation": "fixture-preview-chip" + } + ] + } + } + } + } + } + ] + } + """#.utf8 + ) + + private static let initialPanelWithFreeTextData = Data( + #""" + { + "onResponseReceivedCommands": [ + { + "appendContinuationItemsAction": { + "continuationItems": [ + { + "youChatItemViewModel": { + "sendUserQueryCommand": { + "innertubeCommand": { + "clickTrackingParams": "fixture-panel-free-text-click", + "continuationCommand": { + "request": "CONTINUATION_REQUEST_TYPE_GET_PANEL", + "token": "fixture-panel-free-text-continuation" + } + } + }, + "chipsData": { + "chipData": [ + { + "text": {"simpleText": "Summarize the video"}, + "continuation": "fixture-summary-chip" + }, + { + "text": {"simpleText": "Recommend related content"}, + "continuation": "fixture-related-chip" + }, + { + "text": {"simpleText": "What is the main claim?"}, + "continuation": "fixture-claim-chip" + } + ] + } + } + } + ] + } + } + ] + } + """#.utf8 + ) + + private static let initialPanelFreeTextOnlyData = Data( + #""" + { + "onResponseReceivedCommands": [ + { + "appendContinuationItemsAction": { + "continuationItems": [ + { + "youChatTextMessageViewModel": { + "text": {"content": "Ask me anything about this video."} + } + }, + { + "youChatItemViewModel": { + "sendUserQueryCommand": { + "innertubeCommand": { + "clickTrackingParams": "fixture-panel-free-text-click", + "continuationCommand": { + "request": "CONTINUATION_REQUEST_TYPE_GET_PANEL", + "token": "fixture-panel-free-text-continuation" + } + } + } + } + } + ] + } + } + ] + } + """#.utf8 + ) +} diff --git a/Tests/KasetTests/YouTubeAskPanelLayoutTests.swift b/Tests/KasetTests/YouTubeAskPanelLayoutTests.swift index ef2060a06..b6eaeb3e3 100644 --- a/Tests/KasetTests/YouTubeAskPanelLayoutTests.swift +++ b/Tests/KasetTests/YouTubeAskPanelLayoutTests.swift @@ -70,4 +70,41 @@ struct YouTubeAskPanelLayoutTests { #expect(image.size.height > 300) #expect(image.size.height <= 580) } + + @Test("Free-text composer and Music-style chips remain compact") + func composerAndWrappedSuggestionsUseCompactHeight() async throws { + let client = MockYouTubeClient() + let suggestions = [ + "Summarize the video", + "Recommend related content", + "Explain the main claim", + "List the key moments", + "What should I know?", + "Ask about a detail", + ] + let viewModel = YouTubeAskViewModel(videoID: "fixture-video", client: client) + viewModel.seed(YouTubeAskBootstrap.testing( + suggestions: suggestions, + allowsFreeText: true + )) + viewModel.setExpanded(true) + + for _ in 0 ..< 100 where viewModel.activity != .idle || !viewModel.acceptsFreeTextInput { + await Task.yield() + } + #expect(viewModel.acceptsFreeTextInput) + #expect(viewModel.suggestions.count == suggestions.count) + + let renderer = ImageRenderer(content: YouTubeAskPanelView( + viewModel: viewModel, + maximumHeight: 700, + playerOffsetMilliseconds: 0 + )) + renderer.scale = 1 + renderer.proposedSize = ProposedViewSize(width: 500, height: 700) + + let image = try #require(renderer.nsImage) + #expect(image.size.height > 150) + #expect(image.size.height < 400) + } } diff --git a/Tests/YouTubeAskCoreTests/YouTubeAskFixtureSafetyTests.swift b/Tests/YouTubeAskCoreTests/YouTubeAskFixtureSafetyTests.swift index 7137a5338..1ab339a98 100644 --- a/Tests/YouTubeAskCoreTests/YouTubeAskFixtureSafetyTests.swift +++ b/Tests/YouTubeAskCoreTests/YouTubeAskFixtureSafetyTests.swift @@ -106,6 +106,9 @@ struct YouTubeAskFixtureSafetyTests { let sendFunction = source[sendStart.lowerBound ..< liveStart.lowerBound] #expect(loadFunction.contains("YouTubeAskParser.parseBootstrap")) + #expect(loadFunction.contains("YouTubeAskRequestBuilder.makePanelBootstrapBody")) + #expect(loadFunction.contains("YouTubeAskParser.parseConversation")) + #expect(loadFunction.contains("panelConversation.freeTextCommand")) #expect(sendFunction.contains("YouTubeAskRequestBuilder.makeFreeTextBody")) #expect(sendFunction.contains("YouTubeAskRequestBuilder.makeFreeTextClickTrackingContext")) #expect(!source.contains("private struct AskFreeTextCommand")) @@ -169,6 +172,10 @@ struct YouTubeAskFixtureSafetyTests { #expect(loadFunction.contains("requestSnapshot: AskFreeTextRequestSnapshot")) #expect(loadFunction.contains("requestSnapshot: requestSnapshot")) + #expect(loadFunction.contains("endpoint: \"get_panel\"")) + #expect(loadFunction.contains("bodyData: panelBody")) + #expect(loadFunction.contains("validateBackingStateBeforeSending: true")) + #expect(!loadFunction.contains("YouTubeAskRequestBuilder.makeFreeTextBody")) #expect(sendFunction.contains("requestSnapshot: AskFreeTextRequestSnapshot")) #expect(sendFunction.contains("validateBackingStateBeforeSending: true")) #expect(liveFunction.contains("let requestSnapshot = try await captureAskFreeTextRequestSnapshot()")) @@ -177,6 +184,48 @@ struct YouTubeAskFixtureSafetyTests { #expect(!liveFunction.contains(rawVideoIDOutput)) } + @Test("API Explorer reports free-text capability provenance without opaque values") + func apiExplorerReportsRedactedFreeTextCapability() throws { + let repositoryRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let sourceURL = repositoryRoot + .appendingPathComponent("Sources", isDirectory: true) + .appendingPathComponent("APIExplorer", isDirectory: true) + .appendingPathComponent("AskVideoAudit.swift") + let source = try String(contentsOf: sourceURL, encoding: .utf8) + + let reportStart = try #require(source.range(of: "private struct AskParityReport")) + let evaluationStart = try #require(source.range( + of: "private struct AskParityEvaluation", + range: reportStart.upperBound ..< source.endIndex + )) + let nextStart = try #require(source.range(of: "private func evaluateAskParityNext(")) + let panelStart = try #require(source.range( + of: "private func evaluateAskParityPanel(", + range: nextStart.upperBound ..< source.endIndex + )) + let profileStart = try #require(source.range( + of: "private func evaluateAskParityProfile(", + range: panelStart.upperBound ..< source.endIndex + )) + let reportFunction = source[reportStart.lowerBound ..< evaluationStart.lowerBound] + let nextFunction = source[nextStart.lowerBound ..< panelStart.lowerBound] + let panelFunction = source[panelStart.lowerBound ..< profileStart.lowerBound] + + #expect(reportFunction.contains("free-text-capability: next=")) + #expect(reportFunction.contains("panelFreeTextCapability.rawValue")) + #expect(!reportFunction.contains("continuation")) + #expect(!reportFunction.contains("clickTrackingParams")) + #expect(nextFunction.contains("bootstrap.freeTextCommand == nil ? .absent : .present")) + #expect( + panelFunction.contains( + "panelConversation.freeTextCommand == nil ? .absent : .present" + ) + ) + } + private struct Violation { let rule: String let path: String diff --git a/Tests/YouTubeAskCoreTests/YouTubeAskPanelCapabilityTests.swift b/Tests/YouTubeAskCoreTests/YouTubeAskPanelCapabilityTests.swift new file mode 100644 index 000000000..c9a9a3a04 --- /dev/null +++ b/Tests/YouTubeAskCoreTests/YouTubeAskPanelCapabilityTests.swift @@ -0,0 +1,158 @@ +import Foundation +import Testing +@testable import YouTubeAskCore + +@Suite("YouTube Ask materialized capabilities") +struct YouTubeAskPanelCapabilityTests { + @Test("Extracts free text only from confirmed legacy panel items") + func legacyPanelFreeTextCommand() throws { + let envelope = try Self.envelope( + #""" + { + "onResponseReceivedCommands": [ + { + "appendContinuationItemsAction": { + "continuationItems": [ + { + "youChatItemViewModel": { + "sendUserQueryCommand": { + "innertubeCommand": { + "clickTrackingParams": "fixture-panel-click", + "continuationCommand": { + "request": "CONTINUATION_REQUEST_TYPE_GET_PANEL", + "token": "fixture-panel-free-text" + } + } + } + } + } + ] + } + } + ] + } + """# + ) + + let conversation = try YouTubeAskParser.parseConversation(from: envelope) + + #expect(conversation.freeTextCommand?.continuation == "fixture-panel-free-text") + #expect(conversation.freeTextCommand?.clickTrackingParams == "fixture-panel-click") + } + + @Test("Extracts free text from the confirmed singular list mutation") + func mutationPanelFreeTextCommand() throws { + let envelope = try Self.envelope( + #""" + { + "onResponseReceivedCommand": { + "listMutationCommand": { + "operations": { + "operations": [ + { + "insertItemSectionContent": { + "contents": [ + { + "youChatItemViewModel": { + "sendUserQueryCommand": { + "innertubeCommand": { + "clickTrackingParams": "fixture-mutation-click", + "continuationCommand": { + "request": "CONTINUATION_REQUEST_TYPE_GET_PANEL", + "token": "fixture-mutation-free-text" + } + } + } + } + } + ], + "insertByPositionInSection": { + "position": "INSERTION_POSITION_LAST", + "sectionTargetId": "fixture-section" + } + } + } + ] + } + } + } + } + """# + ) + + let conversation = try YouTubeAskParser.parseConversation(from: envelope) + + #expect(conversation.freeTextCommand?.continuation == "fixture-mutation-free-text") + } + + @Test("Rejects distinct materialized commands and ignores unconfirmed decoys") + func ambiguityAndDecoyHandling() throws { + let ambiguous = try Self.envelope( + #""" + { + "onResponseReceivedCommands": [ + { + "appendContinuationItemsAction": { + "continuationItems": [ + { + "youChatItemViewModel": { + "sendUserQueryCommand": { + "innertubeCommand": { + "clickTrackingParams": "fixture-click-a", + "continuationCommand": { + "request": "CONTINUATION_REQUEST_TYPE_GET_PANEL", + "token": "fixture-command-a" + } + } + } + } + }, + { + "youChatItemViewModel": { + "sendUserQueryCommand": { + "innertubeCommand": { + "clickTrackingParams": "fixture-click-b", + "continuationCommand": { + "request": "CONTINUATION_REQUEST_TYPE_GET_PANEL", + "token": "fixture-command-b" + } + } + } + } + } + ] + } + } + ] + } + """# + ) + expectYouTubeAskError(.ambiguousBootstrap) { + _ = try YouTubeAskParser.parseConversation(from: ambiguous) + } + + let decoy = try Self.envelope( + #""" + { + "youChatItemViewModel": { + "sendUserQueryCommand": { + "innertubeCommand": { + "clickTrackingParams": "fixture-decoy-click", + "continuationCommand": { + "request": "CONTINUATION_REQUEST_TYPE_GET_PANEL", + "token": "fixture-decoy-command" + } + } + } + } + } + """# + ) + let conversation = try YouTubeAskParser.parseConversation(from: decoy) + #expect(conversation.freeTextCommand == nil) + } + + private static func envelope(_ json: String) throws -> YouTubeAskWireEnvelope { + try YouTubeAskWireDecoder.decode(Data(json.utf8)) + } +} diff --git a/docs/adr/0032-youtube-ask-gemini.md b/docs/adr/0032-youtube-ask-gemini.md index 6c7861f53..083b0eae1 100644 --- a/docs/adr/0032-youtube-ask-gemini.md +++ b/docs/adr/0032-youtube-ask-gemini.md @@ -40,9 +40,12 @@ Wire-level observations and the API Explorer workflow remain documented in the 2. **Ship server-commanded one-shot free text plus chips.** An eligible watch page exposes a sparkles action in the top toolbar. Activating it presents a compact, top-centered glass panel and may prepare the initial panel, but never - generates an answer automatically. The composer appears only when the - canonical eligible panel supplies the exact validated `sendUserQueryCommand`. - One free-text turn is allowed per fresh chat and uses `get_panel` with the + generates an answer automatically. The composer is enabled only when the + canonical eligible `next` panel or its prompt-free initial `get_panel` + materialization supplies the exact validated `sendUserQueryCommand`. Kaset + uses the `next` command when present and materializes only the exact + server-issued panel continuation when that capability is missing. One + free-text turn is allowed per fresh chat and uses `get_panel` with the captured `clientMessageId`, string `playerOffsetMs`, `userInputText`, server continuation, and click-tracking context. After any submitted turn, follow-up interaction remains server-chip-only until New Chat. Outside click, Escape, @@ -68,7 +71,9 @@ Wire-level observations and the API Explorer workflow remain documented in the message/chip containers, including the confirmed singular `onResponseReceivedCommand.listMutationCommand` insertion path. Result/link view models and sibling framework updates remain outside the visible model. - A chip may carry the exact observed + If both the watch bootstrap and initial materialization expose a free-text + command, they must agree exactly; distinct commands fail closed rather than + being merged. A chip may carry the exact observed `onClick.listMutationCommand` UI mutation, which is ignored rather than preserved or executed only when its inserted user-turn text matches the chip label and every key matches the allowlisted local user-turn/loading-animation @@ -96,9 +101,11 @@ Wire-level observations and the API Explorer workflow remain documented in the - Ask follows Kaset's API-over-WebView boundary and shares one strict parser and safety implementation between the app and API Explorer. -- V1 accepts one server-commanded free-form question per fresh chat and does - not reproduce YouTube's unvalidated multi-turn free-text fields. Subsequent - turns use server-issued suggestions or New Chat. +- V1 accepts one server-commanded free-form question per fresh chat. The + capability may originate in `next` or the prompt-free initial `get_panel`, but + both paths use the same strict parser, immutable request identity, and opaque + command rules. Kaset does not reproduce YouTube's unvalidated multi-turn + free-text fields. Subsequent turns use server-issued suggestions or New Chat. - Conversation continuity intentionally ends at the watch/account lifecycle boundary and at app termination. - Opaque command material is harder to inspect during debugging, but accidental diff --git a/docs/api-discovery.md b/docs/api-discovery.md index 29b712df9..411274feb 100644 --- a/docs/api-discovery.md +++ b/docs/api-discovery.md @@ -1424,10 +1424,15 @@ and bidirectional formatting characters, hides links and high-entropy opaque strings, and is bounded to 16,000 characters per answer. `ask-video-free-text-test` is a separate, one-shot validation command. It -fetches a fresh authenticated runtime-WEB `next` response and selects the first -complete eligible `PAyouchat` panel, matching the browser's mirrored-panel -behavior without merging opaque commands from responsive duplicates. -The accepted schema is intentionally narrow: +captures one immutable authenticated runtime-WEB request snapshot, fetches a +fresh `next` response, and selects the first complete eligible `PAyouchat` +panel, matching the browser's mirrored-panel behavior without merging opaque +commands from responsive duplicates. If `next` contains the validated +`sendUserQueryCommand`, the loader uses it directly. Otherwise, when the same +bootstrap has one safe panel continuation, the loader sends its prompt-free +initial `get_panel` body through that same snapshot and accepts only the strict +parser's confirmed materialized `freeTextCommand`. The accepted command schema +is intentionally narrow and may originate in either response: ```text sendUserQueryCommand @@ -1438,8 +1443,11 @@ sendUserQueryCommand └── opaque continuation token is present ``` -The command sends one `get_panel` request, without retry, using the exact -server continuation and click-tracking context plus: +Initial materialization contains only the exact panel continuation plus the +snapshot's request context; it has no `formData`, message ID, prompt, or click- +tracking injection and cannot generate an answer. The resolved command then +sends one generated `get_panel` request, without retry, using the exact server +continuation and click-tracking context plus: ```text formData.inputComposerFormData.clientMessageId: youchat- @@ -1448,9 +1456,12 @@ formData.inputComposerFormData.userInputText: private prompt contents ``` The runtime WEB `context` is added by the authenticated request transport. +The same immutable context, headers, API identifier, cookie/account snapshot, +and origin are used for `next`, optional initial materialization, and prompt +submission; the backing identity is revalidated before either `get_panel`. Prompts must come from stdin or a regular file owned by the current user with -exact mode `0600`, no extended ACL, valid UTF-8, and at most -16,000 characters. The command rejects guest, `--authuser`, brand-account, +exact mode `0600`, no extended ACL, valid UTF-8, at most 16,000 characters, and +at most 64 KiB of UTF-8. The command rejects guest, `--authuser`, brand-account, client-version override, verbose, output, raw-body, follow-up, and multi-chat options. Responses use the bounded `YouTubeAskCore` decoder and strict confirmed YouChat parser; only sanitized assistant text and redacted structural metrics are @@ -1489,17 +1500,21 @@ when strict parsing finds one unambiguous panel bootstrap, materializes the initial `get_panel`. It never submits a suggestion chip, free text, or any other generation request. Both responses use the bounded `YouTubeAskCore` wire decoder and strict parser. Terminal output is limited to the profile name, HTTP status, -response size, wire format, eligibility, chip counts, and a redacted failure -category. The command stops at the first passing profile and rejects raw-output, -private-body, client-version override, follow-up, and multi-chat options. +response size, wire format, eligibility, chip counts, whether a validated +free-text capability was present in `next` and initial `get_panel`, and a +redacted failure category. It never prints command continuations or click- +tracking values. The command stops at the first passing profile and rejects +raw-output, private-body, client-version override, follow-up, and multi-chat +options. The read-only run on **July 28, 2026** completed all three profiles. Every `next` request returned HTTP 200, but each response reported the exported session as signed out, so `get_panel` was not run and no profile passed. This is an authentication rejection, not evidence that any request profile is valid or -invalid for an eligible signed-in session. Production therefore remains -disabled and fail-closed; a future run must confirm signed-in primary-account -eligibility before selecting a profile. +invalid for an eligible signed-in session. No profile passed that historical +run. Production was later enabled through the explicitly selected fixed profile; +future compatibility checks must still confirm signed-in primary-account +eligibility and fail closed on unsupported responses. `get_panel`, `streaming_panel`, and `get_answer` must use `wire-action` for manual probes; the raw `action` command rejects them. Supply manual panel JSON through @@ -1517,7 +1532,7 @@ arguments must be plain relative API paths. | Transport | Current interpretation | |-----------|------------------------| -| `get_panel` | Panel bootstrap, direct suggestion chips, and the validated one-shot free-text composer transport | +| `get_panel` | Prompt-free initial panel materialization, direct suggestion chips, materialized free-text capability discovery, and the validated one-shot free-text composer transport | | `streaming_panel` | Frontend capability remains present, but the August 2 free-text candidate returned HTTP 400; not used by production | | `get_watch` | Combined player/watch bootstrap; observed responses use a top-level JSON array | | `get_answer` | Separate AI answer transport; not used by the verified watch-page suggestion flow | @@ -1576,10 +1591,14 @@ shape remains supported for previously validated responses. successfully. No panel materialization or suggestion submission was performed. The free-text composer uses the exact server-issued `sendUserQueryCommand` -continuation and click tracking. The August 2 browser capture showed that the -frontend posts it to `get_panel` with `clientMessageId`, decimal-string -`playerOffsetMs`, and `userInputText`. Production permits that exact shape once -per fresh chat; unvalidated multi-turn fields remain unsupported. +continuation and click tracking. Strict parsing may obtain that capability from +the canonical eligible `next` panel or from the confirmed prompt-free initial +`get_panel` materialization. `next` wins when it already supplies the command; +the initial panel is queried only as a fallback, and distinct commands are never +merged. The August 2 browser capture showed that the frontend posts the resolved +command to `get_panel` with `clientMessageId`, decimal-string `playerOffsetMs`, +and `userInputText`. Production permits that exact shape once per fresh chat; +unvalidated multi-turn fields remain unsupported. **Live validation on July 27, 2026**: @@ -1638,7 +1657,7 @@ The `--brand` flag sets `context.user.onBehalfOfUser` in the request body. See [ | `ask-video-audit ` | Run a redacted, read-only Ask Gemini / YouChat audit without sending a prompt | | `ask-video-parity ` | Test ordered read-only Ask request profiles using only `next` and initial `get_panel`; never submits a chip | | `ask-video-live-test ` | With `--confirm-live-ai`, replay the server-issued summary chip; optionally add `--follow-up` or `--fresh-chats N` | -| `ask-video-free-text-test ` | With `--confirm-live-ai` and `--prompt-file`, validate one exact server-commanded `get_panel` free-text request with no retry | +| `ask-video-free-text-test ` | With `--confirm-live-ai` and `--prompt-file`, resolve the command from `next` or a prompt-free initial `get_panel`, then validate one exact server-commanded free-text request with no retry | | `search-audit ` | Audit live Music search shapes, filter chips, continuations, and parser coverage | | `continuation [ep]` | Explore a continuation (`browse`, `search`, or `next`); use the same auth mode as the originating request (`--guest` for guest search) | | `list` | List all known endpoints | @@ -1680,7 +1699,7 @@ The `--brand` flag sets `context.user.onBehalfOfUser` in the request body. See [ | Date | Changes | |------|---------| -| 2026-08-02 | Browser-validated the one-shot `get_panel` free-text request and response shape; added the guarded `ask-video-free-text-test`; selected the first content-equivalent mirrored YouChat panel; deduplicated repeated visible suggestions; retained one-shot free text plus server-chip follow-ups | +| 2026-08-02 | Browser-validated the one-shot `get_panel` free-text request and response shape; added the guarded `ask-video-free-text-test`; selected the first content-equivalent mirrored YouChat panel; documented and implemented `sendUserQueryCommand` provenance from either `next` or prompt-free initial `get_panel`; added redacted parity capability reporting; deduplicated repeated visible suggestions; retained one-shot free text plus server-chip follow-ups | | 2026-08-01 | Revalidated an eligible signed-in production watch response; added strict support for the observed local user-turn/loading `onClick` mutation, preserved direct chips while discarding ambiguous panel-only commands, and added one bounded read-only retry for internal identity-fence cancellation | | 2026-07-30 | Enabled the fixed WEB Ask request profile in the production app by explicit product direction; eligibility and all strict parser, identity, and transport gates remain enforced | | 2026-07-28 | Added redacted read-only `ask-video-parity` tooling backed by `YouTubeAskCore`; all three profiles returned HTTP 200 `next` responses but the exported session was treated as signed out, so no profile passed and production remains disabled | diff --git a/docs/architecture.md b/docs/architecture.md index 8c5751e3b..0c53edb6f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -72,7 +72,7 @@ YouTube view models follow the same pattern with `YouTubeClientProtocol` and liv ### YouTube Ask State Boundary -Ask Gemini uses a route-owned, memory-only state machine. Visible messages and local suggestion IDs are separated from opaque server commands. The canonical eligible panel may also supply one opaque, one-shot free-text command; its continuation and click tracking are consumed with the first submitted turn and never exposed to the UI. Hidden state is bound to the video ID, authentication generation, confirmed primary-account scope, local conversation ID, and revision. Navigation away, source/account/authentication changes, cancellation, or view-model destruction invalidates the operation and discards both visible and opaque conversation state. Nothing is written to `APICache`, UserDefaults, Keychain, navigation restoration, telemetry, or logs. See [ADR-0032](adr/0032-youtube-ask-gemini.md). +Ask Gemini uses a route-owned, memory-only state machine. Visible messages and local suggestion IDs are separated from opaque server commands. The canonical eligible `next` panel may supply the opaque, one-shot free-text command directly. When it does not, the client may send the prompt-free initial `get_panel` continuation and accept a confirmed materialized command; if both stages provide commands, they must match exactly. The command's continuation and click tracking are consumed with the first submitted turn and never exposed to the UI. Hidden state is bound to the video ID, authentication generation, confirmed primary-account scope, local conversation ID, and revision. Navigation away, source/account/authentication changes, cancellation, or view-model destruction invalidates the operation and discards both visible and opaque conversation state. Nothing is written to `APICache`, UserDefaults, Keychain, navigation restoration, telemetry, or logs. See [ADR-0032](adr/0032-youtube-ask-gemini.md). ## Key Services diff --git a/docs/testing.md b/docs/testing.md index ea8aa4880..986e53b57 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -453,12 +453,15 @@ Button { ### YouTube Ask Compatibility Validation YouTube Ask unit tests are deterministic and offline. The API Explorer parity -workflow is a separate, read-only manual compatibility check: it may send -`next` and prepare the initial panel, but it must never submit a suggestion or -free-form prompt. Guarded chip or free-text generation requires separate explicit -approval and is not part of routine tests or CI. Free-text tests assert the exact -validated `get_panel` body, string playback offset, click-tracking context, -one-shot consumption, and no automatic retry. +workflow is a separate, read-only manual compatibility check: it sends `next` +and prepares the prompt-free initial panel, but it must never submit a suggestion +or free-form prompt. Its redacted report records whether strict parsing found a +free-text capability in `next` and in the initial `get_panel` without printing +opaque values. Guarded chip or free-text generation requires separate explicit +approval and is not part of routine tests or CI. Free-text tests cover direct +`next` capability loading, fallback materialization from the same immutable +request snapshot, exact validated `get_panel` bodies, string playback offset, +click-tracking context, one-shot consumption, and no automatic retry. The production app explicitly selects the fixed WEB request profile. The July 28, 2026 parity run was inconclusive because the exported session appeared signed out; diff --git a/docs/youtube.md b/docs/youtube.md index 7402a523c..e8af37b78 100644 --- a/docs/youtube.md +++ b/docs/youtube.md @@ -116,7 +116,10 @@ non-interactive and undisplayed. Opening the watch-page toolbar panel may prepare an initial panel, but it never generates an answer until the user selects a server-issued suggestion or submits one free-text prompt. Free text is exposed only when the canonical -eligible panel supplies the exact validated `sendUserQueryCommand`; it uses +eligible `next` panel or its prompt-free initial `get_panel` materialization +supplies the exact validated `sendUserQueryCommand`. The client uses the `next` +command when present and otherwise prepares the exact server-issued panel +continuation; it never synthesizes or merges capabilities. Submission uses `get_panel`, not `streaming_panel`, and is one-shot until New Chat. Follow-up chips remain server-issued. Visible labels and answers are sanitized but not localized by Kaset. Assistant messages render @@ -277,8 +280,9 @@ the native scrubber is disabled; YouTube Premium accounts see no ads. formats, strict YouChat parsing, decoy rejection, server order, sanitization, and accidental-secret detection. `YouTubeAskTransportTests`, `YouTubeAskClientTests`, and `YouTubeAskViewModelTests` cover redirect and - response bounds, exact request shapes, identity fencing, single-flight - behavior, New Chat, and command consumption without contacting YouTube. + response bounds, exact request shapes, identity fencing, direct-versus- + materialized free-text capability provenance, single-flight behavior, New + Chat, and command consumption without contacting YouTube. - Ask UI tests are never part of routine verification because they launch the app. Run them only after explicit human approval. The read-only `ask-video-parity` command is a manual compatibility check, not a unit test and From bd586397d315d13f8cec4f68d738a33d5af92e9c Mon Sep 17 00:00:00 2001 From: Sertac Ozercan Date: Sun, 2 Aug 2026 18:19:29 -0700 Subject: [PATCH 13/18] fix(youtube): enable multi-turn Ask prompts Signed-off-by: Sertac Ozercan --- CONTEXT.md | 8 +-- .../Models/YouTube/YouTubeAskModels.swift | 34 +++++++--- Sources/Kaset/Services/YouTubeProtocols.swift | 3 +- .../Views/YouTube/YouTubeAskPanelView.swift | 11 ++-- .../YouTubeAskParser+FreeText.swift | 5 ++ Sources/YouTubeAskCore/YouTubeAskParser.swift | 18 +++--- .../YouTubeAskClientFreeTextTests.swift | 45 ++++++++++--- .../KasetTests/YouTubeAskViewModelTests.swift | 35 +++++++++-- .../YouTubeAskParserMirroredPanelTests.swift | 36 ++++++----- .../YouTubeAskWatchFooterCommandTests.swift | 63 +++++++++++++++++++ docs/adr/0032-youtube-ask-gemini.md | 31 +++++---- docs/api-discovery.md | 33 +++++++--- docs/architecture.md | 2 +- docs/testing.md | 2 +- docs/youtube.md | 17 ++--- 15 files changed, 258 insertions(+), 85 deletions(-) create mode 100644 Tests/YouTubeAskCoreTests/YouTubeAskWatchFooterCommandTests.swift diff --git a/CONTEXT.md b/CONTEXT.md index 29da30fb3..9d7e7ec52 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -54,15 +54,15 @@ The workflows that turn playlist browse data into native playback queues. This i ## YouTube Ask -The watch-page Ask Gemini capability in the regular YouTube experience. Kaset supports server-issued suggestion chips plus one server-commanded free-text turn per fresh chat. It uses YouTube APIs rather than the playback WebView, selects the fixed WEB request profile explicitly in production, and appears only when YouTube returns an eligible bootstrap for a signed-in primary account. +The watch-page Ask Gemini capability in the regular YouTube experience. Kaset supports server-issued suggestion chips plus revision-bound free-text turns using the validated composer command. It uses YouTube APIs rather than the playback WebView, selects the fixed WEB request profile explicitly in production, and appears only when YouTube returns an eligible bootstrap for a signed-in primary account. ## Ask Bootstrap -The eligible YouChat material discovered in the current video's watch-page `next` response. A bootstrap may expose suggestions and the one-shot free-text capability directly, or carry the opaque command needed to prepare the prompt-free initial Ask panel. Initial panel materialization may supply a missing free-text capability. The bootstrap and anything materialized from it are valid only for the video, authentication generation, and primary-account scope that produced them. +The eligible YouChat material discovered in the current video's watch-page `next` response. A bootstrap may expose suggestions and the revision-bound free-text capability directly, or carry the opaque command needed to prepare the prompt-free initial Ask panel. Initial panel materialization may supply a missing free-text capability. The bootstrap and anything materialized from it are valid only for the video, authentication generation, and primary-account scope that produced them. ## Ask Conversation -The memory-only visible transcript, one-shot free-text capability, and server-issued follow-up suggestions for one watch-scoped Ask chat. New Chat replaces it only after a fresh bootstrap and panel preparation succeed. Navigation, account/authentication changes, cancellation, or app termination discard it. +The memory-only visible transcript, revision-bound free-text capability, and server-issued follow-up suggestions for one watch-scoped Ask chat. New Chat replaces it only after a fresh bootstrap and panel preparation succeed. Navigation, account/authentication changes, cancellation, or app termination discard it. ## Server-Issued Ask Suggestion @@ -71,7 +71,7 @@ A sanitized visible chip label paired internally with an opaque server command. ## Server-Issued Ask Free-Text Command -The opaque `sendUserQueryCommand` found either in the canonical eligible watch-page `next` panel or in its prompt-free initial `get_panel` materialization. Kaset uses the `next` command when present; otherwise it may materialize the exact server-issued panel continuation and accept only a confirmed command from that response. The command may be used once per fresh chat with the validated `get_panel` form fields and current playback offset. Its continuation and click-tracking values remain in memory and are never printed or persisted. +The opaque `sendUserQueryCommand` found either in the canonical eligible watch-page `next` panel—currently under its footer `chatInputViewModel` or an eligible YouChat item—or in its prompt-free initial `get_panel` materialization. Kaset uses the `next` command when present; otherwise it may materialize the exact server-issued panel continuation and accept only a confirmed command from that response. Each bound conversation revision may consume the command once with the validated `get_panel` form fields and current playback offset. A successful response advances the revision and keeps the command available unless YouTube supplies a replacement; uncertain failures discard it and require New Chat. Its continuation and click-tracking values remain in memory and are never printed or persisted. ## Opaque Ask Command diff --git a/Sources/Kaset/Models/YouTube/YouTubeAskModels.swift b/Sources/Kaset/Models/YouTube/YouTubeAskModels.swift index f29518aed..e4e6dd598 100644 --- a/Sources/Kaset/Models/YouTube/YouTubeAskModels.swift +++ b/Sources/Kaset/Models/YouTube/YouTubeAskModels.swift @@ -248,7 +248,6 @@ struct YouTubeAskConversation: Sendable { var canSubmitFreeText: Bool { self.binding != nil && self.freeTextCommand != nil - && !self.hasStarted && self.pendingSuggestionID == nil && self.pendingFreeTextInput == nil } @@ -417,16 +416,34 @@ struct YouTubeAskConversation: Sendable { static func testing( messages: [YouTubeAskMessage] = [], - suggestions: [String] = [] + suggestions: [String] = [], + allowsFreeText: Bool = false ) -> YouTubeAskConversation { - YouTubeAskConversation( - id: UUID(), - revision: messages.isEmpty ? 0 : 1, + let id = UUID() + let revision: UInt64 = messages.isEmpty ? 0 : 1 + let binding = allowsFreeText + ? YouTubeAskBindingState( + videoID: "fixture-video", + authenticationGeneration: 0, + accountBinding: YouTubeAskAccountBinding(scopeID: "fixture-scope"), + clientGeneration: 0, + conversationID: id, + revision: revision + ) + : nil + return YouTubeAskConversation( + id: id, + revision: revision, messages: messages, suggestions: suggestions.map { YouTubeAskSuggestion(text: $0) }, suggestionStates: [], - freeTextCommand: nil, - binding: nil, + freeTextCommand: allowsFreeText + ? YouTubeAskOpaqueCommand( + continuation: "fixture-free-text-continuation", + clickTrackingParams: "fixture-click-tracking" + ) + : nil, + binding: binding, pendingSuggestionID: nil, pendingFreeTextInput: nil ) @@ -554,7 +571,8 @@ extension YouTubeAskConversation { return YouTubeAskConversation( previousMessages: conversation.messages, parsed: parsed, - binding: binding.advanced() + binding: binding.advanced(), + freeTextCommand: parsed.freeTextCommand ?? conversation.freeTextCommand ) } diff --git a/Sources/Kaset/Services/YouTubeProtocols.swift b/Sources/Kaset/Services/YouTubeProtocols.swift index 7a869629c..37b138c03 100644 --- a/Sources/Kaset/Services/YouTubeProtocols.swift +++ b/Sources/Kaset/Services/YouTubeProtocols.swift @@ -74,7 +74,8 @@ protocol YouTubeClientProtocol: Sendable { ) async throws -> YouTubeAskConversation /// Submits one validated free-text prompt using the current watch-scoped - /// server command. Free text is one-shot until New Chat in v1. + /// server command. A validated composer command may be reused only after + /// each successful response advances the bound conversation revision. func continueAskConversation( _ conversation: YouTubeAskConversation, submitting userInputText: String, diff --git a/Sources/Kaset/Views/YouTube/YouTubeAskPanelView.swift b/Sources/Kaset/Views/YouTube/YouTubeAskPanelView.swift index 778da0f6d..75233095c 100644 --- a/Sources/Kaset/Views/YouTube/YouTubeAskPanelView.swift +++ b/Sources/Kaset/Views/YouTube/YouTubeAskPanelView.swift @@ -251,16 +251,15 @@ struct YouTubeAskPanelView: View { } private var preferredFocusTarget: FocusTarget? { - // Establish an accessible focus destination while the chat is fresh. - // A server welcome message is still pre-turn content; only a user turn - // should stop the composer from receiving initial focus. + // A renewed composer command belongs to the latest conversation + // revision and should regain focus even after earlier transcript turns. + if self.viewModel.acceptsFreeTextInput { + return .input + } guard !self.viewModel.hasStarted else { return nil } if self.viewModel.presentationError != nil || self.viewModel.isBusy { return .close } - if self.viewModel.acceptsFreeTextInput { - return .input - } if !self.viewModel.requiresNewChat, let suggestionID = self.viewModel.suggestions.first?.id { diff --git a/Sources/YouTubeAskCore/YouTubeAskParser+FreeText.swift b/Sources/YouTubeAskCore/YouTubeAskParser+FreeText.swift index c6a401990..525ef8509 100644 --- a/Sources/YouTubeAskCore/YouTubeAskParser+FreeText.swift +++ b/Sources/YouTubeAskCore/YouTubeAskParser+FreeText.swift @@ -15,6 +15,11 @@ extension YouTubeAskParser { { try commands.append(Self.parseFreeTextCommand(command)) } + if let inputViewModel = object["chatInputViewModel"]?.objectValue, + let command = inputViewModel["sendUserQueryCommand"] + { + try commands.append(Self.parseFreeTextCommand(command)) + } for key in object.keys.sorted() { guard let nested = object[key] else { continue } try Self.collectFreeTextCommands( diff --git a/Sources/YouTubeAskCore/YouTubeAskParser.swift b/Sources/YouTubeAskCore/YouTubeAskParser.swift index 347e931dc..db4103c7c 100644 --- a/Sources/YouTubeAskCore/YouTubeAskParser.swift +++ b/Sources/YouTubeAskCore/YouTubeAskParser.swift @@ -55,15 +55,19 @@ package enum YouTubeAskParser { guard !panelSuggestions.isEmpty || panelFreeTextCommand != nil else { continue } - if let canonicalContent { - guard canonicalContent.suggestions.map(\.label) == panelSuggestions.map(\.label) else { + if let existingContent = canonicalContent { + guard existingContent.suggestions.map(\.label) == panelSuggestions.map(\.label) else { throw YouTubeAskCoreError.malformedChip } - guard Self.freeTextCommandsMatch( - canonicalContent.freeTextCommand, - panelFreeTextCommand - ) else { - throw YouTubeAskCoreError.ambiguousBootstrap + // Responsive surfaces can mirror the same visible panel with + // different opaque commands. Keep one panel atomic: retain the + // first complete panel, or replace an earlier chips-only mirror + // with the first later mirror that also owns the composer. + if existingContent.freeTextCommand == nil, panelFreeTextCommand != nil { + canonicalContent = ( + suggestions: panelSuggestions, + freeTextCommand: panelFreeTextCommand + ) } } else { canonicalContent = ( diff --git a/Tests/KasetTests/YouTubeAskClientFreeTextTests.swift b/Tests/KasetTests/YouTubeAskClientFreeTextTests.swift index f926445f2..c666dc29c 100644 --- a/Tests/KasetTests/YouTubeAskClientFreeTextTests.swift +++ b/Tests/KasetTests/YouTubeAskClientFreeTextTests.swift @@ -11,7 +11,7 @@ extension YouTubeAskClientTests { switch requestCount.increment() { case 1: return Self.response(for: request, data: Self.eligibleNextData) - case 2: + case 2, 3: #expect(request.url?.path == "/youtubei/v1/get_panel") #expect(request.url?.query?.contains("key=") != true) let body = try Self.body(from: request) @@ -30,9 +30,19 @@ extension YouTubeAskClientTests { #expect(Set(formData.keys) == ["inputComposerFormData"]) let composer = try #require(formData["inputComposerFormData"] as? [String: Any]) #expect(Set(composer.keys) == ["clientMessageId", "playerOffsetMs", "userInputText"]) - #expect(composer["clientMessageId"] as? String == "youchat-1000") - #expect(composer["playerOffsetMs"] as? String == "234000") - #expect(composer["userInputText"] as? String == "What is this video about?") + let messageID = try #require(composer["clientMessageId"] as? String) + if requestCount.count == 2 { + #expect(messageID == "youchat-1000") + } else { + let sequence = Int(messageID.dropFirst("youchat-".count)) ?? 0 + #expect(sequence > 1000) + } + let expectedOffset = requestCount.count == 2 ? "234000" : "235000" + let expectedInput = requestCount.count == 2 + ? "What is this video about?" + : "What should I know next?" + #expect(composer["playerOffsetMs"] as? String == expectedOffset) + #expect(composer["userInputText"] as? String == expectedInput) return Self.response(for: request, data: Self.mutationConversationData) default: Issue.record("Free-text Ask request retried unexpectedly") @@ -51,11 +61,6 @@ extension YouTubeAskClientTests { from: #require(page.askBootstrap) ) #expect(conversation.canSubmitFreeText) - let oversizedUTF8 = "a" + String( - repeating: "\u{0301}", - count: 64 * 1024 - ) - #expect(conversation.appendingUserTurn(text: oversizedUTF8) == nil) let pendingConversation = try #require( conversation.appendingUserTurn(text: "What is this video about?") ) @@ -83,7 +88,27 @@ extension YouTubeAskClientTests { ) } #expect(requestCount.count == 2) - #expect(!continued.canSubmitFreeText) + #expect(continued.canSubmitFreeText) #expect(continued.messages.map(\.role).count == 2) + + let secondPending = try #require( + continued.appendingUserTurn(text: "What should I know next?") + ) + let secondContinued = try await client.continueAskConversation( + secondPending, + submitting: "What should I know next?", + playerOffsetMilliseconds: 235_000 + ) + + await #expect(throws: CancellationError.self) { + _ = try await client.continueAskConversation( + secondPending, + submitting: "What should I know next?", + playerOffsetMilliseconds: 235_000 + ) + } + #expect(requestCount.count == 3) + #expect(secondContinued.canSubmitFreeText) + #expect(secondContinued.messages.map(\.role).count == 4) } } diff --git a/Tests/KasetTests/YouTubeAskViewModelTests.swift b/Tests/KasetTests/YouTubeAskViewModelTests.swift index 8bcd9d1c4..a1fb1ff38 100644 --- a/Tests/KasetTests/YouTubeAskViewModelTests.swift +++ b/Tests/KasetTests/YouTubeAskViewModelTests.swift @@ -48,8 +48,8 @@ struct YouTubeAskViewModelTests { #expect(sut.suggestions.map(\.text) == ["Explain the main idea"]) } - @Test("Free text is single-flight, forwards playback offset, and is one-shot") - func freeTextSubmissionIsValidatedAndOneShot() async { + @Test("Free text is single-flight and re-enables after successful responses") + func freeTextSubmissionSupportsMultipleTurns() async { let client = MockYouTubeClient() let sut = YouTubeAskViewModel(videoID: "fixture-video", client: client) sut.seed(YouTubeAskBootstrap.testing( @@ -73,7 +73,8 @@ struct YouTubeAskViewModelTests { YouTubeAskMessage(role: .user, text: "What is this video about?"), YouTubeAskMessage(role: .assistant, text: "A fixture answer."), ], - suggestions: ["Ask a follow-up"] + suggestions: ["Ask a follow-up"], + allowsFreeText: true ) let gate = AsyncGate() client.beforeAskContinuationReturn = { @@ -102,9 +103,35 @@ struct YouTubeAskViewModelTests { "What is this video about?", "A fixture answer.", ]) - #expect(!sut.acceptsFreeTextInput) + #expect(sut.acceptsFreeTextInput) #expect(sut.canStartNewChat) #expect(sut.accessibilityAnnouncement == .responseReady) + + client.continuedAskConversation = YouTubeAskConversation.testing( + messages: [ + YouTubeAskMessage(role: .user, text: "What is this video about?"), + YouTubeAskMessage(role: .assistant, text: "A fixture answer."), + YouTubeAskMessage(role: .user, text: "What should I know next?"), + YouTubeAskMessage(role: .assistant, text: "A second fixture answer."), + ], + suggestions: ["Another follow-up"], + allowsFreeText: true + ) + sut.inputText = "What should I know next?" + sut.submitInput(playerOffsetMilliseconds: 235_000) + await self.waitUntil( + sut.activity == .idle + && client.continueAskFreeTextCallCount == 2 + && sut.messages.count == 4 + ) + + #expect(client.submittedAskFreeTextInputs == [ + "What is this video about?", + "What should I know next?", + ]) + #expect(client.submittedAskPlayerOffsets == [234_000, 235_000]) + #expect(sut.acceptsFreeTextInput) + #expect(sut.suggestions.map(\.text) == ["Another follow-up"]) } @Test("Suggestion selection publishes the user turn, stays single-flight, and preserves server order") diff --git a/Tests/YouTubeAskCoreTests/YouTubeAskParserMirroredPanelTests.swift b/Tests/YouTubeAskCoreTests/YouTubeAskParserMirroredPanelTests.swift index 718151f2b..c602afbf8 100644 --- a/Tests/YouTubeAskCoreTests/YouTubeAskParserMirroredPanelTests.swift +++ b/Tests/YouTubeAskCoreTests/YouTubeAskParserMirroredPanelTests.swift @@ -49,29 +49,32 @@ extension YouTubeAskParserTests { } } - @Test("Rejects a missing free-text command across content-equivalent panels") - func rejectsMissingFreeTextCommandAcrossMirrors() throws { + @Test("Selects the first complete mirror after an earlier chips-only panel") + func selectsFirstCompleteMirror() throws { let envelope = try Self.envelope([ "engagementPanels": [ + Self.eligiblePanel(chips: [ + ("Summarize the video", "fixture-summary-preview"), + ]), Self.eligiblePanel( - chips: [("Summarize the video", "fixture-summary-primary")], + chips: [("Summarize the video", "fixture-summary-complete")], freeTextCommand: Self.freeTextCommand( - continuation: "fixture-free-text-command" + continuation: "fixture-free-text-complete" ) ), - Self.eligiblePanel(chips: [ - ("Summarize the video", "fixture-summary-mirror"), - ]), ], ]) - expectYouTubeAskError(.ambiguousBootstrap) { - _ = try YouTubeAskParser.parseBootstrap(from: envelope) - } + let parsedBootstrap = try YouTubeAskParser.parseBootstrap(from: envelope) + let bootstrap = try #require(parsedBootstrap) + #expect(bootstrap.suggestions.map(\.command.continuation) == [ + "fixture-summary-complete", + ]) + #expect(bootstrap.freeTextCommand?.continuation == "fixture-free-text-complete") } - @Test("Rejects distinct free-text commands across content-equivalent panels") - func rejectsDistinctFreeTextCommandsAcrossMirrors() throws { + @Test("Keeps the first complete content-equivalent mirrored panel") + func keepsFirstCompleteMirroredPanel() throws { let envelope = try Self.envelope([ "engagementPanels": [ Self.eligiblePanel( @@ -89,9 +92,12 @@ extension YouTubeAskParserTests { ], ]) - expectYouTubeAskError(.ambiguousBootstrap) { - _ = try YouTubeAskParser.parseBootstrap(from: envelope) - } + let parsedBootstrap = try YouTubeAskParser.parseBootstrap(from: envelope) + let bootstrap = try #require(parsedBootstrap) + #expect(bootstrap.suggestions.map(\.command.continuation) == [ + "fixture-summary-primary", + ]) + #expect(bootstrap.freeTextCommand?.continuation == "fixture-free-text-primary") } @Test("Free-text capability survives ambiguous mirrored panel continuations") diff --git a/Tests/YouTubeAskCoreTests/YouTubeAskWatchFooterCommandTests.swift b/Tests/YouTubeAskCoreTests/YouTubeAskWatchFooterCommandTests.swift new file mode 100644 index 000000000..456c8b2bc --- /dev/null +++ b/Tests/YouTubeAskCoreTests/YouTubeAskWatchFooterCommandTests.swift @@ -0,0 +1,63 @@ +import Foundation +import Testing +@testable import YouTubeAskCore + +@Suite("YouTube Ask watch footer command") +struct YouTubeAskWatchFooterCommandTests { + @Test("Extracts the live footer chat-input command from an eligible watch panel") + func watchPanelFooterChatInputCommand() throws { + let envelope = try YouTubeAskWireDecoder.decode(Data( + #""" + { + "engagementPanels": [ + { + "engagementPanelSectionListRenderer": { + "panelIdentifier": "PAyouchat", + "content": { + "youChatItemViewModel": { + "chipsData": { + "chipData": [ + { + "text": {"simpleText": "Summarize the video"}, + "continuation": "fixture-summary-chip" + }, + { + "text": {"simpleText": "Recommend related content"}, + "continuation": "fixture-related-chip" + } + ] + } + } + }, + "footer": { + "chatInputViewModel": { + "sendUserQueryCommand": { + "innertubeCommand": { + "clickTrackingParams": "fixture-footer-click", + "continuationCommand": { + "request": "CONTINUATION_REQUEST_TYPE_GET_PANEL", + "token": "fixture-footer-free-text" + } + } + } + } + } + } + } + ] + } + """#.utf8 + )) + + let parsedBootstrap = try YouTubeAskParser.parseBootstrap(from: envelope) + let bootstrap = try #require(parsedBootstrap) + + #expect(bootstrap.panelCommand == nil) + #expect(bootstrap.suggestions.map(\.label) == [ + "Summarize the video", + "Recommend related content", + ]) + #expect(bootstrap.freeTextCommand?.continuation == "fixture-footer-free-text") + #expect(bootstrap.freeTextCommand?.clickTrackingParams == "fixture-footer-click") + } +} diff --git a/docs/adr/0032-youtube-ask-gemini.md b/docs/adr/0032-youtube-ask-gemini.md index 083b0eae1..47e9ae550 100644 --- a/docs/adr/0032-youtube-ask-gemini.md +++ b/docs/adr/0032-youtube-ask-gemini.md @@ -37,18 +37,22 @@ Wire-level observations and the API Explorer workflow remain documented in the `next` response is shared with normal watch-page parsing. WebViews remain limited to authentication and DRM playback; Kaset does not scrape or drive the Ask Gemini DOM. -2. **Ship server-commanded one-shot free text plus chips.** An eligible watch +2. **Ship server-commanded revision-bound free text plus chips.** An eligible watch page exposes a sparkles action in the top toolbar. Activating it presents a compact, top-centered glass panel and may prepare the initial panel, but never generates an answer automatically. The composer is enabled only when the canonical eligible `next` panel or its prompt-free initial `get_panel` - materialization supplies the exact validated `sendUserQueryCommand`. Kaset - uses the `next` command when present and materializes only the exact - server-issued panel continuation when that capability is missing. One - free-text turn is allowed per fresh chat and uses `get_panel` with the - captured `clientMessageId`, string `playerOffsetMs`, `userInputText`, server - continuation, and click-tracking context. After any submitted turn, follow-up - interaction remains server-chip-only until New Chat. Outside click, Escape, + materialization supplies the exact validated `sendUserQueryCommand`. In the + current watch response this command is owned by the eligible panel footer's + `chatInputViewModel`; confirmed materialized `youChatItemViewModel` content is + also supported. Kaset uses the `next` command when present and materializes only the exact + server-issued panel continuation when that capability is missing. Free-text + submission uses `get_panel` with the captured `clientMessageId`, string + `playerOffsetMs`, `userInputText`, server continuation, and click-tracking + context. Each conversation revision may consume one chip or free-text action. + A successful response advances the revision and keeps the validated composer + command available unless YouTube supplies a replacement; uncertain failures + discard all opaque state and require New Chat. Outside click, Escape, or the close control dismisses the surface without discarding the conversation. 3. **Scope all conversation state to the current watch and account.** Ask is available only to an eligible signed-in primary account. Hidden state is @@ -101,11 +105,12 @@ Wire-level observations and the API Explorer workflow remain documented in the - Ask follows Kaset's API-over-WebView boundary and shares one strict parser and safety implementation between the app and API Explorer. -- V1 accepts one server-commanded free-form question per fresh chat. The - capability may originate in `next` or the prompt-free initial `get_panel`, but - both paths use the same strict parser, immutable request identity, and opaque - command rules. Kaset does not reproduce YouTube's unvalidated multi-turn - free-text fields. Subsequent turns use server-issued suggestions or New Chat. +- V1 accepts repeated server-commanded free-form questions with one action per + bound conversation revision. The capability may originate in `next` or the + prompt-free initial `get_panel`; both paths use the same strict parser, + immutable request identity, and opaque-command rules. Successful responses + advance the revision without inventing additional composer fields. Uncertain + failures require New Chat. - Conversation continuity intentionally ends at the watch/account lifecycle boundary and at app termination. - Opaque command material is harder to inspect during debugging, but accidental diff --git a/docs/api-discovery.md b/docs/api-discovery.md index 411274feb..6f97aaf6d 100644 --- a/docs/api-discovery.md +++ b/docs/api-discovery.md @@ -1432,7 +1432,11 @@ commands from responsive duplicates. If `next` contains the validated bootstrap has one safe panel continuation, the loader sends its prompt-free initial `get_panel` body through that same snapshot and accepts only the strict parser's confirmed materialized `freeTextCommand`. The accepted command schema -is intentionally narrow and may originate in either response: +is intentionally narrow and may originate in either response. The August 2 +watch response placed it at +`engagementPanelSectionListRenderer.footer.chatInputViewModel.sendUserQueryCommand`; +confirmed materialized panel items may expose the same command under +`youChatItemViewModel.sendUserQueryCommand`: ```text sendUserQueryCommand @@ -1481,9 +1485,8 @@ displayed or saved. - The response returned HTTP 200 as a JSON object with the confirmed singular `onResponseReceivedCommand.listMutationCommand` shape. The existing bounded decoder and strict conversation parser accept that response container. -- No second arbitrary-text turn was sent. Production therefore treats free text - as one-shot per fresh chat; follow-up interaction remains server-chip-only or - starts with New Chat. +- No second arbitrary-text turn was sent in this browser capture, so multi-turn + behavior remained unvalidated at that point. **Read-only production-parity matrix (added July 28, 2026):** @@ -1532,7 +1535,7 @@ arguments must be plain relative API paths. | Transport | Current interpretation | |-----------|------------------------| -| `get_panel` | Prompt-free initial panel materialization, direct suggestion chips, materialized free-text capability discovery, and the validated one-shot free-text composer transport | +| `get_panel` | Prompt-free initial panel materialization, direct suggestion chips, materialized free-text capability discovery, and the validated free-text composer transport | | `streaming_panel` | Frontend capability remains present, but the August 2 free-text candidate returned HTTP 400; not used by production | | `get_watch` | Combined player/watch bootstrap; observed responses use a top-level JSON array | | `get_answer` | Separate AI answer transport; not used by the verified watch-page suggestion flow | @@ -1597,8 +1600,21 @@ the canonical eligible `next` panel or from the confirmed prompt-free initial the initial panel is queried only as a fallback, and distinct commands are never merged. The August 2 browser capture showed that the frontend posts the resolved command to `get_panel` with `clientMessageId`, decimal-string `playerOffsetMs`, -and `userInputText`. Production permits that exact shape once per fresh chat; -unvalidated multi-turn fields remain unsupported. +and `userInputText`. Production permits that exact shape once per bound +conversation revision. A successful response advances the revision and retains +the validated composer command when the response omits a replacement; no +additional multi-turn fields are invented. + +**Live production validation on August 3, 2026:** + +- Two free-text prompts succeeded in the same watch-scoped chat. +- The first response contained no replacement `sendUserQueryCommand`; Kaset + retained the original validated composer command after advancing the bound + conversation revision. +- The second request reused the validated `get_panel` shape with a fresh + monotonic `clientMessageId`, current playback offset, and new `userInputText`. +- Each revision remained single-consumption: stale pending copies were rejected + before network access, and there was no automatic retry. **Live validation on July 27, 2026**: @@ -1699,7 +1715,8 @@ The `--brand` flag sets `context.user.onBehalfOfUser` in the request body. See [ | Date | Changes | |------|---------| -| 2026-08-02 | Browser-validated the one-shot `get_panel` free-text request and response shape; added the guarded `ask-video-free-text-test`; selected the first content-equivalent mirrored YouChat panel; documented and implemented `sendUserQueryCommand` provenance from either `next` or prompt-free initial `get_panel`; added redacted parity capability reporting; deduplicated repeated visible suggestions; retained one-shot free text plus server-chip follow-ups | +| 2026-08-03 | Live-validated two free-text prompts in one chat; retained the validated composer command across successful bound revisions, enforced one action per revision, and preserved stale-revision rejection/no-retry behavior | +| 2026-08-02 | Browser-validated the `get_panel` free-text request and response shape; added the guarded `ask-video-free-text-test`; selected the first content-equivalent mirrored YouChat panel; documented and implemented `sendUserQueryCommand` provenance from the watch footer `chatInputViewModel`, an eligible YouChat item, or prompt-free initial `get_panel`; added redacted parity capability reporting; deduplicated repeated visible suggestions; retained server-chip follow-ups | | 2026-08-01 | Revalidated an eligible signed-in production watch response; added strict support for the observed local user-turn/loading `onClick` mutation, preserved direct chips while discarding ambiguous panel-only commands, and added one bounded read-only retry for internal identity-fence cancellation | | 2026-07-30 | Enabled the fixed WEB Ask request profile in the production app by explicit product direction; eligibility and all strict parser, identity, and transport gates remain enforced | | 2026-07-28 | Added redacted read-only `ask-video-parity` tooling backed by `YouTubeAskCore`; all three profiles returned HTTP 200 `next` responses but the exported session was treated as signed out, so no profile passed and production remains disabled | diff --git a/docs/architecture.md b/docs/architecture.md index 0c53edb6f..329fb2af1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -72,7 +72,7 @@ YouTube view models follow the same pattern with `YouTubeClientProtocol` and liv ### YouTube Ask State Boundary -Ask Gemini uses a route-owned, memory-only state machine. Visible messages and local suggestion IDs are separated from opaque server commands. The canonical eligible `next` panel may supply the opaque, one-shot free-text command directly. When it does not, the client may send the prompt-free initial `get_panel` continuation and accept a confirmed materialized command; if both stages provide commands, they must match exactly. The command's continuation and click tracking are consumed with the first submitted turn and never exposed to the UI. Hidden state is bound to the video ID, authentication generation, confirmed primary-account scope, local conversation ID, and revision. Navigation away, source/account/authentication changes, cancellation, or view-model destruction invalidates the operation and discards both visible and opaque conversation state. Nothing is written to `APICache`, UserDefaults, Keychain, navigation restoration, telemetry, or logs. See [ADR-0032](adr/0032-youtube-ask-gemini.md). +Ask Gemini uses a route-owned, memory-only state machine. Visible messages and local suggestion IDs are separated from opaque server commands. The canonical eligible `next` panel may supply the opaque free-text command directly. When it does not, the client may send the prompt-free initial `get_panel` continuation and accept a confirmed materialized command; if both stages provide commands, they must match exactly. One action consumes each bound conversation revision. A successful response advances the revision and retains the validated composer command unless YouTube supplies a replacement; uncertain failures discard it and require New Chat. Continuation and click-tracking values are never exposed to the UI. Hidden state is bound to the video ID, authentication generation, confirmed primary-account scope, local conversation ID, and revision. Navigation away, source/account/authentication changes, cancellation, or view-model destruction invalidates the operation and discards both visible and opaque conversation state. Nothing is written to `APICache`, UserDefaults, Keychain, navigation restoration, telemetry, or logs. See [ADR-0032](adr/0032-youtube-ask-gemini.md). ## Key Services diff --git a/docs/testing.md b/docs/testing.md index 986e53b57..b3c8b6229 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -461,7 +461,7 @@ opaque values. Guarded chip or free-text generation requires separate explicit approval and is not part of routine tests or CI. Free-text tests cover direct `next` capability loading, fallback materialization from the same immutable request snapshot, exact validated `get_panel` bodies, string playback offset, -click-tracking context, one-shot consumption, and no automatic retry. +click-tracking context, per-revision consumption, repeated free-text turns with monotonic message IDs, stale-revision rejection, and no automatic retry. The production app explicitly selects the fixed WEB request profile. The July 28, 2026 parity run was inconclusive because the exported session appeared signed out; diff --git a/docs/youtube.md b/docs/youtube.md index e8af37b78..40c5fce24 100644 --- a/docs/youtube.md +++ b/docs/youtube.md @@ -115,13 +115,15 @@ non-interactive and undisplayed. Opening the watch-page toolbar panel may prepare an initial panel, but it never generates an answer until the user selects a server-issued suggestion or -submits one free-text prompt. Free text is exposed only when the canonical +submits free text. Free text is exposed only when the canonical eligible `next` panel or its prompt-free initial `get_panel` materialization supplies the exact validated `sendUserQueryCommand`. The client uses the `next` command when present and otherwise prepares the exact server-issued panel continuation; it never synthesizes or merges capabilities. Submission uses -`get_panel`, not `streaming_panel`, and is one-shot until New Chat. Follow-up -chips remain server-issued. Visible labels and +`get_panel`, not `streaming_panel`. Each conversation revision may consume one +chip or free-text action; successful responses advance the revision and keep the +validated composer available unless YouTube supplies a replacement. Follow-up +chips remain server-issued, and any uncertain failure requires New Chat. Visible labels and answers are sanitized but not localized by Kaset. Assistant messages render native Markdown blocks and inline emphasis; link destinations are stripped and never become interactive. @@ -295,10 +297,11 @@ the native scrubber is disabled; YouTube Premium accounts see no ads. YouChat bootstrap for the signed-in primary account and current video. See [ADR-0032](adr/0032-youtube-ask-gemini.md) and the [API discovery record](api-discovery.md#youtube-ask-gemini--youchat-investigation-2026-07-27). -- Ask Gemini v1 intentionally limits free text to one validated `get_panel` - turn per fresh chat. It omits unvalidated multi-turn composer fields, - `streaming_panel`, brand accounts, persisted conversations, telemetry, - clickable generated links, and Apple Intelligence dependencies. +- Ask Gemini supports repeated free-text turns with the validated `get_panel` + shape and one consumed action per bound conversation revision. It does not + invent additional multi-turn fields, and still omits `streaming_panel`, brand + accounts, persisted conversations, telemetry, clickable generated links, and + Apple Intelligence dependencies. - No auto-advance to the next related video after `VIDEO_ENDED` (YouTube autonav is disabled; Kaset shows the ended state — the bar's next button advances manually). From aa4f2fd915da5c96bd561826a899b2409dddc5cb Mon Sep 17 00:00:00 2001 From: Sertac Ozercan Date: Mon, 3 Aug 2026 00:06:54 -0700 Subject: [PATCH 14/18] fix: address Ask Gemini review regressions Signed-off-by: Sertac Ozercan --- .../API/MockUITestYouTubeClient.swift | 13 ++++-- Sources/Kaset/Services/Auth/AuthService.swift | 4 +- .../Utilities/YouTubeAskPlayerOffset.swift | 17 +++++++ .../Views/YouTube/YouTubeWatchView.swift | 6 +-- Tests/KasetTests/AuthServiceTests.swift | 37 +++++++++++++++- .../KasetTests/LoginCompletionGateTests.swift | 10 +++-- .../YouTubeWatchRegressionTests.swift | 44 +++++++++++++++++++ 7 files changed, 117 insertions(+), 14 deletions(-) create mode 100644 Sources/Kaset/Utilities/YouTubeAskPlayerOffset.swift create mode 100644 Tests/KasetTests/YouTubeWatchRegressionTests.swift diff --git a/Sources/Kaset/Services/API/MockUITestYouTubeClient.swift b/Sources/Kaset/Services/API/MockUITestYouTubeClient.swift index 2fb7777c1..d71968db1 100644 --- a/Sources/Kaset/Services/API/MockUITestYouTubeClient.swift +++ b/Sources/Kaset/Services/API/MockUITestYouTubeClient.swift @@ -4,15 +4,22 @@ import Foundation /// Returns deterministic fixture data so UI tests never hit the network. @MainActor final class MockUITestYouTubeClient: YouTubeClientProtocol { - private var isAskGeminiEligible = - UITestConfig.environmentValue(for: UITestConfig.mockAskGeminiEnabledKey) == "true" + private let isAskGeminiEligible: Bool + + init( + isAskGeminiEligible: Bool = + UITestConfig.environmentValue(for: UITestConfig.mockAskGeminiEnabledKey) == "true" + ) { + self.isAskGeminiEligible = isAskGeminiEligible + } var hasMoreHomeFeed: Bool { false } func resetSessionStateForAccountSwitch() { - self.isAskGeminiEligible = false + // This mock retains no transient Ask conversation state. Eligibility is + // launch configuration and must survive normal account-scope resets. } func getHomeFeed() async throws -> YouTubeFeed { diff --git a/Sources/Kaset/Services/Auth/AuthService.swift b/Sources/Kaset/Services/Auth/AuthService.swift index 56732d2a0..2c7c53f51 100644 --- a/Sources/Kaset/Services/Auth/AuthService.swift +++ b/Sources/Kaset/Services/Auth/AuthService.swift @@ -193,8 +193,8 @@ final class AuthService: AuthServiceProtocol { self.logger.info("Ignoring login request while sign-out is in progress") return } - guard !self.loginCleanupRequired, !self.isLoginCleanupInProgress else { - self.logger.info("Ignoring login request while failed-login cleanup is pending") + guard !self.isLoginCleanupInProgress else { + self.logger.info("Ignoring login request while failed-login cleanup is in progress") return } guard self.state != .loggingIn, self.activeLoginAttemptID == nil else { diff --git a/Sources/Kaset/Utilities/YouTubeAskPlayerOffset.swift b/Sources/Kaset/Utilities/YouTubeAskPlayerOffset.swift new file mode 100644 index 000000000..496917d7e --- /dev/null +++ b/Sources/Kaset/Utilities/YouTubeAskPlayerOffset.swift @@ -0,0 +1,17 @@ +import Foundation + +enum YouTubeAskPlayerOffset { + static func milliseconds(for progress: Double) -> Int64 { + guard progress.isFinite, progress > 0 else { + return 0 + } + + let milliseconds = (progress * 1000).rounded() + guard milliseconds.isFinite, + milliseconds < Double(Int64.max) + else { + return Int64.max + } + return Int64(milliseconds) + } +} diff --git a/Sources/Kaset/Views/YouTube/YouTubeWatchView.swift b/Sources/Kaset/Views/YouTube/YouTubeWatchView.swift index 0dfe26942..03220e403 100644 --- a/Sources/Kaset/Views/YouTube/YouTubeWatchView.swift +++ b/Sources/Kaset/Views/YouTube/YouTubeWatchView.swift @@ -146,12 +146,10 @@ struct YouTubeWatchView: View { } private var askPlayerOffsetMilliseconds: Int64 { - guard self.youtubePlayer.currentVideo?.videoId == self.video.videoId, - self.youtubePlayer.progress.isFinite - else { + guard self.youtubePlayer.currentVideo?.videoId == self.video.videoId else { return 0 } - return Int64(max(0, self.youtubePlayer.progress * 1000).rounded()) + return YouTubeAskPlayerOffset.milliseconds(for: self.youtubePlayer.progress) } // MARK: - Ambient Style Picker (PROTOTYPE) diff --git a/Tests/KasetTests/AuthServiceTests.swift b/Tests/KasetTests/AuthServiceTests.swift index 2651bb12f..6f2263eee 100644 --- a/Tests/KasetTests/AuthServiceTests.swift +++ b/Tests/KasetTests/AuthServiceTests.swift @@ -533,6 +533,34 @@ struct AuthServiceTests { #expect(self.mockWebKitManager.getSAPISIDCallCount == 0) } + @Test("Sign in after failed sign-out opens cleanup recovery") + func signInAfterFailedSignOutOpensCleanupRecovery() async { + self.authService.completeLogin(sapisid: "test-sapisid") + self.mockWebKitManager.clearAllDataResult = false + #expect(await !self.authService.signOut()) + + self.authService.startLogin() + guard let cleanupAttemptID = self.authService.activeLoginAttemptID else { + Issue.record("Expected a cleanup recovery attempt") + return + } + + #expect(self.authService.state == .loggingIn) + #expect(self.authService.loginCleanupRequired) + #expect(self.authService.shouldUseCookieFreePlaybackDataStore) + #expect(self.authService.shouldPersistGuestPlaybackState) + + let didRecover = await self.authService.clearFailedLoginAfterDraining( + expectedAttemptID: cleanupAttemptID, + expectedSignOutSequence: self.authService.signOutSequence, + clearCookies: { true } + ) + + #expect(didRecover == true) + #expect(self.authService.state == .loggedOut) + #expect(!self.authService.loginCleanupRequired) + } + @Test("Login-status checks do not consume an active login attempt") func loginStatusCheckDoesNotConsumeActiveAttempt() async { self.authService.startLogin() @@ -588,6 +616,13 @@ struct AuthServiceTests { await releaseCookieRead.wait() } + self.authService.startLogin() + guard let cleanupAttemptID = self.authService.activeLoginAttemptID else { + Issue.record("Expected a cleanup-owned login attempt") + return + } + self.authService.cancelLoginIfNeeded(expectedAttemptID: cleanupAttemptID) + let loginCheck = Task { @MainActor in await self.authService.checkLoginStatus() } @@ -596,7 +631,7 @@ struct AuthServiceTests { let releaseCleanup = AsyncGate() let cleanup = Task { @MainActor in await self.authService.clearFailedLoginAfterDraining( - expectedAttemptID: LoginAttemptID(rawValue: 999), + expectedAttemptID: cleanupAttemptID, expectedSignOutSequence: self.authService.signOutSequence, clearCookies: { await cleanupStarted.open() diff --git a/Tests/KasetTests/LoginCompletionGateTests.swift b/Tests/KasetTests/LoginCompletionGateTests.swift index de37438e4..c2518cf2c 100644 --- a/Tests/KasetTests/LoginCompletionGateTests.swift +++ b/Tests/KasetTests/LoginCompletionGateTests.swift @@ -488,8 +488,8 @@ struct LoginCompletionGateTests { #expect(authService.state == .loggedOut) } - @Test("Failed durable cleanup blocks another sign-in attempt") - func failedDurableCleanupBlocksAnotherSignInAttempt() async { + @Test("Failed durable cleanup opens a cleanup recovery attempt") + func failedDurableCleanupOpensRecoveryAttempt() async { let webKitManager = MockWebKitManager() webKitManager.finalizeLoginCookieBackupResult = false webKitManager.rollbackLoginCookieBackupResult = .failed @@ -517,8 +517,10 @@ struct LoginCompletionGateTests { #expect(authService.state == .loggedOut) #expect(authService.loginCleanupRequired) authService.startLogin() - #expect(authService.activeLoginAttemptID == nil) - #expect(authService.state == .loggedOut) + #expect(authService.activeLoginAttemptID != nil) + #expect(authService.activeLoginAttemptID != attemptID) + #expect(authService.state == .loggingIn) + #expect(authService.loginCleanupRequired) } @Test("Stale cleanup cannot expire a replacement login attempt") diff --git a/Tests/KasetTests/YouTubeWatchRegressionTests.swift b/Tests/KasetTests/YouTubeWatchRegressionTests.swift new file mode 100644 index 000000000..288f4d7a3 --- /dev/null +++ b/Tests/KasetTests/YouTubeWatchRegressionTests.swift @@ -0,0 +1,44 @@ +import Testing +@testable import Kaset + +@Suite("YouTube watch regressions") +@MainActor +struct YouTubeWatchRegressionTests { + @Test("Ask player offset converts normal progress to rounded milliseconds") + func askPlayerOffsetConvertsNormalProgress() { + #expect(YouTubeAskPlayerOffset.milliseconds(for: 12.3456) == 12346) + } + + @Test("Ask player offset clamps negative progress to zero") + func askPlayerOffsetClampsNegativeProgress() { + #expect(YouTubeAskPlayerOffset.milliseconds(for: -42) == 0) + } + + @Test("Ask player offset clamps huge finite progress without trapping") + func askPlayerOffsetClampsHugeFiniteProgress() { + #expect( + YouTubeAskPlayerOffset.milliseconds(for: Double.greatestFiniteMagnitude) + == Int64.max + ) + } + + @Test("Ask player offset maps non-finite progress to zero") + func askPlayerOffsetRejectsNonFiniteProgress() { + #expect(YouTubeAskPlayerOffset.milliseconds(for: .nan) == 0) + #expect(YouTubeAskPlayerOffset.milliseconds(for: .infinity) == 0) + #expect(YouTubeAskPlayerOffset.milliseconds(for: -.infinity) == 0) + } + + @Test("UI-test Ask capability survives an account-scope session reset") + func mockAskCapabilitySurvivesAccountReset() async throws { + let client = MockUITestYouTubeClient(isAskGeminiEligible: true) + + let initialPage = try await client.getWatchPage(videoId: "mock-video-1") + #expect(initialPage.askBootstrap != nil) + + client.resetSessionStateForAccountSwitch() + + let resetPage = try await client.getWatchPage(videoId: "mock-video-1") + #expect(resetPage.askBootstrap != nil) + } +} From 4be07c13574958bc2c32080623f4e875c899fc40 Mon Sep 17 00:00:00 2001 From: Sertac Ozercan Date: Mon, 3 Aug 2026 01:06:02 -0700 Subject: [PATCH 15/18] fix: address follow-up Ask review findings Signed-off-by: Sertac Ozercan --- .../Models/YouTube/YouTubeAskModels.swift | 21 ++- .../Services/API/YouTubeClient+Ask.swift | 2 +- .../Kaset/Views/AccountSwitcherPopover.swift | 67 ++++++++- .../YouTube/YouTubeAskMarkdownView.swift | 7 +- .../Views/YouTube/YouTubeAskPanelView.swift | 30 ++-- .../AccountSwitcherSignOutTests.swift | 42 ++++++ Tests/KasetTests/YouTubeAskClientTests.swift | 9 +- .../YouTubeAskTruncationTests.swift | 132 ++++++++++++++++++ 8 files changed, 286 insertions(+), 24 deletions(-) create mode 100644 Tests/KasetTests/AccountSwitcherSignOutTests.swift create mode 100644 Tests/KasetTests/YouTubeAskTruncationTests.swift diff --git a/Sources/Kaset/Models/YouTube/YouTubeAskModels.swift b/Sources/Kaset/Models/YouTube/YouTubeAskModels.swift index e4e6dd598..07000e889 100644 --- a/Sources/Kaset/Models/YouTube/YouTubeAskModels.swift +++ b/Sources/Kaset/Models/YouTube/YouTubeAskModels.swift @@ -60,11 +60,18 @@ struct YouTubeAskMessage: Identifiable, Sendable { let id: UUID let role: Role let text: String + let wasTruncated: Bool - init(id: UUID = UUID(), role: Role, text: String) { + init( + id: UUID = UUID(), + role: Role, + text: String, + wasTruncated: Bool = false + ) { self.id = id self.role = role self.text = text + self.wasTruncated = wasTruncated } } @@ -282,7 +289,11 @@ struct YouTubeAskConversation: Sendable { ) } let assistantMessages = parsed.messages.map { parsedMessage in - YouTubeAskMessage(role: .assistant, text: parsedMessage.text) + YouTubeAskMessage( + role: .assistant, + text: parsedMessage.text, + wasTruncated: parsedMessage.wasTruncated + ) } self.init( messages: previousMessages + assistantMessages, @@ -546,7 +557,11 @@ extension YouTubeAskConversation { if parsed.suggestions.isEmpty { let assistantMessages = parsed.messages.map { parsedMessage in - YouTubeAskMessage(role: .assistant, text: parsedMessage.text) + YouTubeAskMessage( + role: .assistant, + text: parsedMessage.text, + wasTruncated: parsedMessage.wasTruncated + ) } return YouTubeAskConversation( messages: assistantMessages, diff --git a/Sources/Kaset/Services/API/YouTubeClient+Ask.swift b/Sources/Kaset/Services/API/YouTubeClient+Ask.swift index 93612919d..ae0d70807 100644 --- a/Sources/Kaset/Services/API/YouTubeClient+Ask.swift +++ b/Sources/Kaset/Services/API/YouTubeClient+Ask.swift @@ -200,7 +200,7 @@ extension YouTubeClient { // statuses expire the matching identity generation, never a newer // session that signed in while this request was in flight. self.handleAskAuthenticationFailure(snapshot: snapshot) - throw YouTubeAskClientError.authenticationRequired + throw YTMusicError.authExpired case 429: throw YouTubeAskClientError.rateLimited default: diff --git a/Sources/Kaset/Views/AccountSwitcherPopover.swift b/Sources/Kaset/Views/AccountSwitcherPopover.swift index 32c127763..19d87d662 100644 --- a/Sources/Kaset/Views/AccountSwitcherPopover.swift +++ b/Sources/Kaset/Views/AccountSwitcherPopover.swift @@ -5,6 +5,26 @@ import SwiftUI +// MARK: - AccountSwitcherSignOutDisposition + +enum AccountSwitcherSignOutDisposition: Equatable { + case dismiss + case presentFailure +} + +// MARK: - AccountSwitcherSignOutFlow + +@MainActor +enum AccountSwitcherSignOutFlow { + static func perform( + prepareForSignOut: () async -> Void, + signOut: () async -> Bool + ) async -> AccountSwitcherSignOutDisposition { + await prepareForSignOut() + return await signOut() ? .dismiss : .presentFailure + } +} + // MARK: - AccountSwitcherPopover /// A Liquid Glass styled popover for switching between accounts. @@ -18,6 +38,8 @@ struct AccountSwitcherPopover: View { @State private var isGuestModeHovering = false @State private var isSignOutHovering = false + @State private var isSigningOut = false + @State private var signOutFailurePresented = false /// Namespace for glass effect morphing. @Namespace private var popoverNamespace @@ -42,6 +64,20 @@ struct AccountSwitcherPopover: View { .compatGlassID("accountSwitcherPopover", in: self.popoverNamespace) } .accessibilityIdentifier(AccessibilityID.AccountSwitcher.container) + .alert( + String(localized: "Sign Out Incomplete"), + isPresented: self.$signOutFailurePresented + ) { + Button(String(localized: "Retry")) { + self.startSignOut() + } + Button(String(localized: "OK"), role: .cancel) {} + } message: { + Text( + "Kaset could not remove saved sign-in data. Try signing out again before quitting.", + comment: "Sign-out durable storage failure message" + ) + } } // MARK: - Header @@ -176,11 +212,7 @@ struct AccountSwitcherPopover: View { private var signOutButton: some View { Button(role: .destructive) { - Task { - await self.accountService.prepareForSignOut() - await self.authService.signOut() - self.dismiss() - } + self.startSignOut() } label: { HStack(spacing: 10) { Image(systemName: "rectangle.portrait.and.arrow.right") @@ -201,9 +233,34 @@ struct AccountSwitcherPopover: View { .onHover { hovering in self.isSignOutHovering = hovering } + .disabled(self.isSigningOut) .accessibilityIdentifier(AccessibilityID.AccountSwitcher.signOutButton) } + private func startSignOut() { + Task { @MainActor in + guard !self.isSigningOut else { return } + self.isSigningOut = true + defer { self.isSigningOut = false } + + let disposition = await AccountSwitcherSignOutFlow.perform( + prepareForSignOut: { + await self.accountService.prepareForSignOut() + }, + signOut: { + await self.authService.signOut() + } + ) + + switch disposition { + case .dismiss: + self.dismiss() + case .presentFailure: + self.signOutFailurePresented = true + } + } + } + @ViewBuilder private var signOutBackground: some View { if self.isSignOutHovering { diff --git a/Sources/Kaset/Views/YouTube/YouTubeAskMarkdownView.swift b/Sources/Kaset/Views/YouTube/YouTubeAskMarkdownView.swift index 9f536632c..dc3e24aa1 100644 --- a/Sources/Kaset/Views/YouTube/YouTubeAskMarkdownView.swift +++ b/Sources/Kaset/Views/YouTube/YouTubeAskMarkdownView.swift @@ -134,8 +134,10 @@ enum YouTubeAskMarkdown { return attributed } - static func plainText(from markdown: String) -> String { - self.blocks(from: markdown).map { block in + static let truncationIndicator = "…" + + static func plainText(from markdown: String, wasTruncated: Bool = false) -> String { + let plainText = self.blocks(from: markdown).map { block in switch block { case let .heading(_, text), let .paragraph(text), let .blockQuote(text): String(self.inlineAttributedString(text).characters) @@ -155,6 +157,7 @@ enum YouTubeAskMarkdown { } .filter { !$0.isEmpty } .joined(separator: "\n") + return wasTruncated ? plainText + self.truncationIndicator : plainText } private static func normalizedLines(from markdown: String) -> [String] { diff --git a/Sources/Kaset/Views/YouTube/YouTubeAskPanelView.swift b/Sources/Kaset/Views/YouTube/YouTubeAskPanelView.swift index 75233095c..a74566f35 100644 --- a/Sources/Kaset/Views/YouTube/YouTubeAskPanelView.swift +++ b/Sources/Kaset/Views/YouTube/YouTubeAskPanelView.swift @@ -425,18 +425,26 @@ struct YouTubeAskPanelView: View { ) ) case .assistant: - YouTubeAskMarkdownView(markdown: message.text) - .foregroundStyle(.primary) - .multilineTextAlignment(.leading) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .leading) - .accessibilityElement(children: .ignore) - .accessibilityLabel( - String( - localized: "YouTube response: \(YouTubeAskMarkdown.plainText(from: message.text))", - comment: "VoiceOver label for an assistant turn in the YouTube Ask Gemini transcript" - ) + VStack(alignment: .leading, spacing: 2) { + YouTubeAskMarkdownView(markdown: message.text) + + if message.wasTruncated { + Text(verbatim: YouTubeAskMarkdown.truncationIndicator) + .font(.callout) + .accessibilityHidden(true) + } + } + .foregroundStyle(.primary) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .ignore) + .accessibilityLabel( + String( + localized: "YouTube response: \(YouTubeAskMarkdown.plainText(from: message.text, wasTruncated: message.wasTruncated))", + comment: "VoiceOver label for an assistant turn in the YouTube Ask Gemini transcript" ) + ) } } diff --git a/Tests/KasetTests/AccountSwitcherSignOutTests.swift b/Tests/KasetTests/AccountSwitcherSignOutTests.swift new file mode 100644 index 000000000..61baa689b --- /dev/null +++ b/Tests/KasetTests/AccountSwitcherSignOutTests.swift @@ -0,0 +1,42 @@ +import Testing +@testable import Kaset + +@Suite("Account switcher sign-out") +@MainActor +struct AccountSwitcherSignOutTests { + @Test("Successful sign-out dismisses only after account preparation") + func successfulSignOutDismissesAfterPreparation() async { + var events: [String] = [] + + let disposition = await AccountSwitcherSignOutFlow.perform( + prepareForSignOut: { + events.append("prepare") + }, + signOut: { + events.append("signOut") + return true + } + ) + + #expect(events == ["prepare", "signOut"]) + #expect(disposition == .dismiss) + } + + @Test("Failed durable sign-out presents recovery without dismissing") + func failedSignOutPresentsRecovery() async { + var events: [String] = [] + + let disposition = await AccountSwitcherSignOutFlow.perform( + prepareForSignOut: { + events.append("prepare") + }, + signOut: { + events.append("signOut") + return false + } + ) + + #expect(events == ["prepare", "signOut"]) + #expect(disposition == .presentFailure) + } +} diff --git a/Tests/KasetTests/YouTubeAskClientTests.swift b/Tests/KasetTests/YouTubeAskClientTests.swift index 230a9ff3d..b38a899f6 100644 --- a/Tests/KasetTests/YouTubeAskClientTests.swift +++ b/Tests/KasetTests/YouTubeAskClientTests.swift @@ -382,7 +382,7 @@ struct YouTubeAskClientTests { #expect(requestCount.count == 2) } - @Test("Panel authentication failures invalidate the matching session", arguments: [401, 403]) + @Test("Panel authentication failures throw shared auth expiry and invalidate the matching session", arguments: [401, 403]) @MainActor func authenticationFailureMapping(statusCode: Int) async throws { let requestCount = LockedCounter() @@ -403,8 +403,13 @@ struct YouTubeAskClientTests { let identityGeneration = authService.accountIdentityGeneration let page = try await client.getWatchPage(videoId: "fixture-video") - await #expect(throws: YouTubeAskClientError.authenticationRequired) { + do { _ = try await client.loadAskConversation(from: #require(page.askBootstrap)) + Issue.record("Expected YTMusicError.authExpired") + } catch YTMusicError.authExpired { + // Expected shared authentication-expiry contract. + } catch { + Issue.record("Expected YTMusicError.authExpired, got \(type(of: error))") } #expect(requestCount.count == 2) diff --git a/Tests/KasetTests/YouTubeAskTruncationTests.swift b/Tests/KasetTests/YouTubeAskTruncationTests.swift new file mode 100644 index 000000000..adb2ff45c --- /dev/null +++ b/Tests/KasetTests/YouTubeAskTruncationTests.swift @@ -0,0 +1,132 @@ +import Foundation +import Testing +@testable import Kaset + +@Suite("YouTube Ask truncation", .serialized) +struct YouTubeAskTruncationTests { + @Test("Materialized messages preserve parser truncation state") + @MainActor + func materializedMessagesPreserveTruncation() async throws { + let requestCount = LockedCounter() + let responseData = try Self.conversationData(answerCharacterCount: 16001) + let session = MockURLProtocol.makeMockSession { request in + switch requestCount.increment() { + case 1: + return YouTubeAskClientTests.response(for: request, data: Self.panelOnlyNextData) + case 2: + return YouTubeAskClientTests.response(for: request, data: responseData) + default: + Issue.record("Truncated materialization retried unexpectedly") + return YouTubeAskClientTests.response(for: request, data: Data(#"{}"#.utf8)) + } + } + defer { MockURLProtocol.reset(session: session) } + + let (client, _) = try await YouTubeAskClientTests.makeAuthenticatedClient(session: session) + let page = try await client.getWatchPage(videoId: "fixture-video") + let conversation = try await client.loadAskConversation(from: #require(page.askBootstrap)) + + #expect(requestCount.count == 2) + #expect(conversation.messages.count == 1) + let message = try #require(conversation.messages.first) + #expect(message.text.count == 16000) + #expect(message.wasTruncated) + } + + @Test("Continued messages preserve parser truncation state") + @MainActor + func continuedMessagesPreserveTruncation() async throws { + let requestCount = LockedCounter() + let responseData = try Self.conversationData(answerCharacterCount: 16001) + let session = MockURLProtocol.makeMockSession { request in + switch requestCount.increment() { + case 1: + return YouTubeAskClientTests.response(for: request, data: YouTubeAskClientTests.eligibleNextData) + case 2: + return YouTubeAskClientTests.response(for: request, data: responseData) + default: + Issue.record("Truncated continuation retried unexpectedly") + return YouTubeAskClientTests.response(for: request, data: Data(#"{}"#.utf8)) + } + } + defer { MockURLProtocol.reset(session: session) } + + let (client, _) = try await YouTubeAskClientTests.makeAuthenticatedClient(session: session) + let page = try await client.getWatchPage(videoId: "fixture-video") + let bootstrap = try #require(page.askBootstrap) + let conversation = try await client.loadAskConversation(from: bootstrap) + let suggestionID = try #require(conversation.suggestions.first?.id) + let pending = try #require(conversation.appendingUserTurn(for: suggestionID)) + let continued = try await client.continueAskConversation(pending, selecting: suggestionID) + + #expect(requestCount.count == 2) + #expect(continued.messages.count == 2) + let message = try #require(continued.messages.last) + #expect(message.role == .assistant) + #expect(message.text.count == 16000) + #expect(message.wasTruncated) + } + + @Test("Truncation marker is visible in accessibility text") + func truncationMarkerIsVisibleInAccessibilityText() { + #expect(YouTubeAskMarkdown.plainText(from: "**Bounded answer**") == "Bounded answer") + #expect(YouTubeAskMarkdown.plainText( + from: "**Bounded answer**", + wasTruncated: true + ) == "Bounded answer…") + } + + private static let panelOnlyNextData = Data( + #""" + { + "contents": {}, + "engagementPanels": [ + { + "engagementPanelSectionListRenderer": { + "panelIdentifier": "PAyouchat", + "continuationEndpoint": { + "continuationCommand": { + "request": "CONTINUATION_REQUEST_TYPE_GET_PANEL", + "token": "fixture-panel-continuation" + } + } + } + } + ] + } + """#.utf8 + ) + + private static func conversationData(answerCharacterCount: Int) throws -> Data { + try JSONSerialization.data(withJSONObject: [ + "onResponseReceivedCommands": [ + [ + "appendContinuationItemsAction": [ + "continuationItems": [ + [ + "youChatTextMessageViewModel": [ + "text": [ + "content": String(repeating: "A", count: answerCharacterCount), + ], + ], + ], + [ + "youChatItemViewModel": [ + "sendUserQueryCommand": [ + "innertubeCommand": [ + "clickTrackingParams": "fixture-free-text-click-tracking", + "continuationCommand": [ + "request": "CONTINUATION_REQUEST_TYPE_GET_PANEL", + "token": "fixture-free-text-continuation", + ], + ], + ], + ], + ], + ], + ], + ], + ], + ]) + } +} From d5b40895527602012a4108194239abbfd1e13e1d Mon Sep 17 00:00:00 2001 From: Sertac Ozercan Date: Mon, 3 Aug 2026 02:29:49 -0700 Subject: [PATCH 16/18] fix: harden sign-out and watch task restarts Signed-off-by: Sertac Ozercan --- .../API/MockUITestYouTubeClient.swift | 6 +- .../YouTube/YouTubeWatchViewModel.swift | 90 ++++++++++++- .../Kaset/Views/AccountSwitcherPopover.swift | 17 +-- .../AccountSwitcherSignOutTests.swift | 72 +++++++---- .../KasetTests/YouTubeAskViewModelTests.swift | 121 ++++++++++++++++++ .../YouTubeWatchRegressionTests.swift | 38 ++++++ 6 files changed, 302 insertions(+), 42 deletions(-) diff --git a/Sources/Kaset/Services/API/MockUITestYouTubeClient.swift b/Sources/Kaset/Services/API/MockUITestYouTubeClient.swift index d71968db1..41bd676a4 100644 --- a/Sources/Kaset/Services/API/MockUITestYouTubeClient.swift +++ b/Sources/Kaset/Services/API/MockUITestYouTubeClient.swift @@ -143,7 +143,8 @@ final class MockUITestYouTubeClient: YouTubeClientProtocol { text: "This is a synthetic YouTube-generated response for UI tests." ), ], - suggestions: ["Show another detail"] + suggestions: ["Show another detail"], + allowsFreeText: self.isAskGeminiEligible ) } @@ -159,7 +160,8 @@ final class MockUITestYouTubeClient: YouTubeClientProtocol { text: "This is a synthetic free-text response for UI tests." ), ], - suggestions: ["Show another detail"] + suggestions: ["Show another detail"], + allowsFreeText: self.isAskGeminiEligible ) } diff --git a/Sources/Kaset/ViewModels/YouTube/YouTubeWatchViewModel.swift b/Sources/Kaset/ViewModels/YouTube/YouTubeWatchViewModel.swift index 662bb79bc..f093fd40e 100644 --- a/Sources/Kaset/ViewModels/YouTube/YouTubeWatchViewModel.swift +++ b/Sources/Kaset/ViewModels/YouTube/YouTubeWatchViewModel.swift @@ -80,6 +80,16 @@ final class YouTubeWatchViewModel { private var commentsContinuation: String? private var commentsGeneration = 0 + /// The single in-flight comments page, shared by concurrent callers so a + /// SwiftUI watch-task restart adopts the initial request instead of + /// cancelling it with the outer task or issuing a duplicate request. + private var commentsLoadTask: Task? + + /// Distinguishes watch initialization from explicit comment pagination. + /// Once the first page reaches a terminal result, repeated watch-task runs + /// preserve loaded watch/Ask state without automatically fetching page two. + private var didCompleteInitialCommentsLoad = false + /// Params for posting a comment (nil = signed out / disabled). private(set) var createCommentParams: String? @@ -118,7 +128,10 @@ final class YouTubeWatchViewModel { self.resetAccountScopedWatchState() } - guard self.loadingState != .loaded else { return } + if self.loadingState == .loaded { + await self.loadInitialCommentsIfNeeded() + return + } self.loadGeneration += 1 let generation = self.loadGeneration self.ask.cancelAndDiscard() @@ -129,9 +142,10 @@ final class YouTubeWatchViewModel { self.data = page.data self.isSubscribed = page.data.isSubscribed ?? false self.commentsContinuation = page.data.commentsContinuation + self.didCompleteInitialCommentsLoad = false self.ask.seed(page.askBootstrap) self.loadingState = .loaded - await self.loadMoreComments() + await self.loadInitialCommentsIfNeeded() } catch { guard generation == self.loadGeneration else { return } self.ask.cancelAndDiscard() @@ -170,7 +184,10 @@ final class YouTubeWatchViewModel { /// Invalidates the current route load and discards all Ask state. func cancel() { self.loadGeneration += 1 + self.commentsLoadTask?.cancel() + self.commentsLoadTask = nil self.commentsGeneration += 1 + self.didCompleteInitialCommentsLoad = false self.isLoadingComments = false self.isPostingComment = false self.commentsContinuation = nil @@ -181,7 +198,10 @@ final class YouTubeWatchViewModel { private func resetAccountScopedWatchState() { self.loadGeneration += 1 + self.commentsLoadTask?.cancel() + self.commentsLoadTask = nil self.commentsGeneration += 1 + self.didCompleteInitialCommentsLoad = false self.data = .empty self.ask.cancelAndDiscard() self.isSubscribed = false @@ -199,35 +219,91 @@ final class YouTubeWatchViewModel { // MARK: - Comments + private enum CommentsLoadOutcome { + case completed + case cancelled + case unavailable + } + + /// Loads the first comments page once for watch initialization. A restarted + /// outer `.task` coalesces onto the stored page request, while later task + /// runs remain no-ops after that first page reaches a terminal result. + private func loadInitialCommentsIfNeeded() async { + guard !self.didCompleteInitialCommentsLoad else { return } + _ = await self.loadCommentsPage() + } + /// Loads the next page of comments. func loadMoreComments() async { - guard !self.isLoadingComments, let continuation = self.commentsContinuation else { return } + _ = await self.loadCommentsPage() + } + + /// Coalesces every caller for the current comments page onto one + /// unstructured task. The task survives cancellation of an awaiting SwiftUI + /// `.task`; only an explicit route/account reset cancels it. + private func loadCommentsPage() async -> CommentsLoadOutcome { + if let existing = self.commentsLoadTask { + return await existing.value + } + guard let continuation = self.commentsContinuation else { + self.didCompleteInitialCommentsLoad = true + return .unavailable + } let generation = self.commentsGeneration - self.isLoadingComments = true + let marksInitialCompletion = !self.didCompleteInitialCommentsLoad + let task = Task { + await self.performCommentsLoad( + continuation: continuation, + generation: generation, + marksInitialCompletion: marksInitialCompletion + ) + } + self.commentsLoadTask = task + return await task.value + } + + private func performCommentsLoad( + continuation: String, + generation: Int, + marksInitialCompletion: Bool + ) async -> CommentsLoadOutcome { defer { if generation == self.commentsGeneration { + self.commentsLoadTask = nil self.isLoadingComments = false } } + guard generation == self.commentsGeneration, !Task.isCancelled else { + return .cancelled + } + self.isLoadingComments = true do { let page = try await self.client.getComments(continuation: continuation) guard generation == self.commentsGeneration, self.commentsContinuation == continuation - else { return } + else { return .cancelled } let existing = Set(self.comments.map(\.id)) self.comments.append(contentsOf: page.comments.filter { !existing.contains($0.id) }) self.commentsContinuation = page.continuation if let params = page.createCommentParams { self.createCommentParams = params } + if marksInitialCompletion { + self.didCompleteInitialCommentsLoad = true + } + return .completed } catch { if error is CancellationError { - return + return .cancelled } - guard generation == self.commentsGeneration else { return } + guard generation == self.commentsGeneration else { return .cancelled } self.logger.error("Failed to load comments: \(error.localizedDescription)") self.commentsContinuation = nil + if marksInitialCompletion { + self.didCompleteInitialCommentsLoad = true + } + return .completed } } diff --git a/Sources/Kaset/Views/AccountSwitcherPopover.swift b/Sources/Kaset/Views/AccountSwitcherPopover.swift index 19d87d662..3e1ff02ef 100644 --- a/Sources/Kaset/Views/AccountSwitcherPopover.swift +++ b/Sources/Kaset/Views/AccountSwitcherPopover.swift @@ -16,12 +16,12 @@ enum AccountSwitcherSignOutDisposition: Equatable { @MainActor enum AccountSwitcherSignOutFlow { + /// AuthService owns durable preflight and its registered account preparation. + /// Callers must not prepare AccountService independently before this operation. static func perform( - prepareForSignOut: () async -> Void, signOut: () async -> Bool ) async -> AccountSwitcherSignOutDisposition { - await prepareForSignOut() - return await signOut() ? .dismiss : .presentFailure + await signOut() ? .dismiss : .presentFailure } } @@ -243,14 +243,9 @@ struct AccountSwitcherPopover: View { self.isSigningOut = true defer { self.isSigningOut = false } - let disposition = await AccountSwitcherSignOutFlow.perform( - prepareForSignOut: { - await self.accountService.prepareForSignOut() - }, - signOut: { - await self.authService.signOut() - } - ) + let disposition = await AccountSwitcherSignOutFlow.perform { + await self.authService.signOut() + } switch disposition { case .dismiss: diff --git a/Tests/KasetTests/AccountSwitcherSignOutTests.swift b/Tests/KasetTests/AccountSwitcherSignOutTests.swift index 61baa689b..8e241939e 100644 --- a/Tests/KasetTests/AccountSwitcherSignOutTests.swift +++ b/Tests/KasetTests/AccountSwitcherSignOutTests.swift @@ -4,39 +4,67 @@ import Testing @Suite("Account switcher sign-out") @MainActor struct AccountSwitcherSignOutTests { - @Test("Successful sign-out dismisses only after account preparation") - func successfulSignOutDismissesAfterPreparation() async { + @Test("Successful sign-out dismisses after the auth service completes") + func successfulSignOutDismissesAfterServiceCompletes() async { var events: [String] = [] - let disposition = await AccountSwitcherSignOutFlow.perform( - prepareForSignOut: { - events.append("prepare") - }, - signOut: { - events.append("signOut") - return true - } - ) + let disposition = await AccountSwitcherSignOutFlow.perform { + events.append("signOut") + return true + } - #expect(events == ["prepare", "signOut"]) + #expect(events == ["signOut"]) #expect(disposition == .dismiss) } + @Test("Failed sign-out preflight leaves account operations available and retry succeeds") + func failedSignOutPreflightLeavesAccountOperationsAvailableAndRetrySucceeds() async { + let webKitManager = MockWebKitManager() + webKitManager.invalidateAuthCookieRestorationResult = false + let authService = AuthService(webKitManager: webKitManager) + let client = MockYTMusicClient() + let accountService = AccountService( + ytMusicClient: client, + authService: authService, + webKitManager: webKitManager + ) + let primaryAccount = MockUserAccountData.primaryAccount + client.accountsListResponse = AccountsListResponse( + googleEmail: "owner@example.test", + accounts: [primaryAccount] + ) + authService.completeLogin(sapisid: "test-sapisid") + + let disposition = await AccountSwitcherSignOutFlow.perform { + await authService.signOut() + } + await accountService.fetchAccounts() + + #expect(disposition == .presentFailure) + #expect(authService.state == .loggedIn(sapisid: "test-sapisid")) + #expect(accountService.currentAccount?.id == primaryAccount.id) + #expect(!webKitManager.clearAllDataCalled) + + webKitManager.invalidateAuthCookieRestorationResult = true + let retryDisposition = await AccountSwitcherSignOutFlow.perform { + await authService.signOut() + } + + #expect(retryDisposition == .dismiss) + #expect(authService.state == .loggedOut) + #expect(webKitManager.clearAllDataCalled) + } + @Test("Failed durable sign-out presents recovery without dismissing") func failedSignOutPresentsRecovery() async { var events: [String] = [] - let disposition = await AccountSwitcherSignOutFlow.perform( - prepareForSignOut: { - events.append("prepare") - }, - signOut: { - events.append("signOut") - return false - } - ) + let disposition = await AccountSwitcherSignOutFlow.perform { + events.append("signOut") + return false + } - #expect(events == ["prepare", "signOut"]) + #expect(events == ["signOut"]) #expect(disposition == .presentFailure) } } diff --git a/Tests/KasetTests/YouTubeAskViewModelTests.swift b/Tests/KasetTests/YouTubeAskViewModelTests.swift index a1fb1ff38..c52040d0d 100644 --- a/Tests/KasetTests/YouTubeAskViewModelTests.swift +++ b/Tests/KasetTests/YouTubeAskViewModelTests.swift @@ -329,6 +329,127 @@ struct YouTubeAskViewModelTests { #expect(sut.ask.suggestions.map(\.text) == ["Continue"]) } + @Test("A restarted watch task coalesces the pending initial comment load") + func restartedWatchTaskResumesInitialComments() async { + let client = MockYouTubeClient() + let commentsGate = AsyncGate() + client.watchNextData = WatchNextData( + videoTitle: "Fixture title", + viewCountText: nil, + publishedText: nil, + channel: nil, + related: [], + commentsContinuation: "fixture-comments" + ) + client.askBootstrap = YouTubeAskBootstrap.testing(suggestions: ["Explain this video"]) + client.commentsPage = YouTubeCommentsPage( + comments: [ + YouTubeComment( + id: "fixture-comment", + author: "Fixture author", + authorAvatarURL: nil, + text: "Fixture comment", + publishedText: nil, + likeCountText: nil + ), + ], + continuation: "fixture-comments-page-2", + createCommentParams: nil + ) + client.beforeCommentsReturn = { _ in + await commentsGate.wait() + } + let video = MockYouTubeClient.makeVideo(videoId: "fixture-video") + let sut = YouTubeWatchViewModel(video: video, client: client) + let accountScope = self.accountScope(sequence: 1) + + let initialTask = Task { + await sut.load(accountScope: accountScope) + } + await self.waitUntil(sut.loadingState == .loaded && client.getCommentsCallCount == 1) + + sut.ask.setExpanded(true) + await self.waitUntil(sut.ask.activity == .idle && !sut.ask.suggestions.isEmpty) + initialTask.cancel() + let restartedTask = Task { + await sut.load(accountScope: accountScope) + } + await Task.yield() + + #expect(client.getWatchPageCallCount == 1) + #expect(client.getCommentsCallCount == 1) + #expect(sut.data.videoTitle == "Fixture title") + #expect(sut.ask.suggestions.map(\.text) == ["Explain this video"]) + + await commentsGate.open() + await initialTask.value + await restartedTask.value + await sut.load(accountScope: accountScope) + + #expect(client.getWatchPageCallCount == 1) + #expect(client.getCommentsCallCount == 1) + #expect(sut.comments.map(\.id) == ["fixture-comment"]) + #expect(sut.canLoadMoreComments) + #expect(sut.data.videoTitle == "Fixture title") + #expect(sut.ask.suggestions.map(\.text) == ["Explain this video"]) + } + + @Test("A repeated watch task does not paginate beyond the initial comments page") + func repeatedWatchTaskDoesNotPaginateComments() async { + let client = MockYouTubeClient() + client.watchNextData = WatchNextData( + videoTitle: "Fixture title", + viewCountText: nil, + publishedText: nil, + channel: nil, + related: [], + commentsContinuation: "fixture-initial-comments" + ) + client.commentsPage = YouTubeCommentsPage( + comments: [ + YouTubeComment( + id: "fixture-initial-comment", + author: "Fixture author", + authorAvatarURL: nil, + text: "Initial comment", + publishedText: nil, + likeCountText: nil + ), + ], + continuation: "fixture-next-comments", + createCommentParams: nil + ) + let video = MockYouTubeClient.makeVideo(videoId: "fixture-video") + let sut = YouTubeWatchViewModel(video: video, client: client) + + await sut.load() + await sut.load() + + #expect(client.getWatchPageCallCount == 1) + #expect(client.getCommentsCallCount == 1) + #expect(sut.comments.map(\.id) == ["fixture-initial-comment"]) + #expect(sut.canLoadMoreComments) + + client.commentsPage = YouTubeCommentsPage( + comments: [ + YouTubeComment( + id: "fixture-next-comment", + author: "Fixture author", + authorAvatarURL: nil, + text: "Next comment", + publishedText: nil, + likeCountText: nil + ), + ], + continuation: nil, + createCommentParams: nil + ) + await sut.loadMoreComments() + + #expect(client.getCommentsCallCount == 2) + #expect(sut.comments.map(\.id) == ["fixture-initial-comment", "fixture-next-comment"]) + } + @Test("An internal identity cancellation retries the read-only watch page once") func internalIdentityCancellationRetriesWatchPage() async { let client = MockYouTubeClient() diff --git a/Tests/KasetTests/YouTubeWatchRegressionTests.swift b/Tests/KasetTests/YouTubeWatchRegressionTests.swift index 288f4d7a3..1336ca14f 100644 --- a/Tests/KasetTests/YouTubeWatchRegressionTests.swift +++ b/Tests/KasetTests/YouTubeWatchRegressionTests.swift @@ -41,4 +41,42 @@ struct YouTubeWatchRegressionTests { let resetPage = try await client.getWatchPage(videoId: "mock-video-1") #expect(resetPage.askBootstrap != nil) } + + @Test("UI-test suggestion continuation keeps free-text Ask enabled") + func mockSuggestionContinuationKeepsFreeTextEnabled() async throws { + let client = MockUITestYouTubeClient(isAskGeminiEligible: true) + let page = try await client.getWatchPage(videoId: "mock-video-1") + let bootstrap = try #require(page.askBootstrap) + let conversation = try await client.loadAskConversation(from: bootstrap) + let suggestion = try #require(conversation.suggestions.first) + let pendingConversation = try #require( + conversation.appendingUserTurn(for: suggestion.id) + ) + + let continuedConversation = try await client.continueAskConversation( + pendingConversation, + selecting: suggestion.id + ) + + #expect(continuedConversation.canSubmitFreeText) + } + + @Test("UI-test free-text continuation keeps free-text Ask enabled") + func mockFreeTextContinuationKeepsFreeTextEnabled() async throws { + let client = MockUITestYouTubeClient(isAskGeminiEligible: true) + let page = try await client.getWatchPage(videoId: "mock-video-1") + let bootstrap = try #require(page.askBootstrap) + let conversation = try await client.loadAskConversation(from: bootstrap) + let pendingConversation = try #require( + conversation.appendingUserTurn(text: "What happens next?") + ) + + let continuedConversation = try await client.continueAskConversation( + pendingConversation, + submitting: "What happens next?", + playerOffsetMilliseconds: 1000 + ) + + #expect(continuedConversation.canSubmitFreeText) + } } From 4dd2fc025c2658512b05079ee7280fc2d6a7472d Mon Sep 17 00:00:00 2001 From: Sertac Ozercan Date: Mon, 3 Aug 2026 03:13:28 -0700 Subject: [PATCH 17/18] fix(youtube): preserve floating playback on account refresh Signed-off-by: Sertac Ozercan --- .../YouTubeWatchPlaybackLifecycle.swift | 37 +++++ .../Views/YouTube/YouTubeWatchView.swift | 36 ++--- .../YouTubeWatchPlaybackLifecycleTests.swift | 142 ++++++++++++++++++ 3 files changed, 194 insertions(+), 21 deletions(-) create mode 100644 Sources/Kaset/Views/YouTube/YouTubeWatchPlaybackLifecycle.swift create mode 100644 Tests/KasetTests/YouTubeWatchPlaybackLifecycleTests.swift diff --git a/Sources/Kaset/Views/YouTube/YouTubeWatchPlaybackLifecycle.swift b/Sources/Kaset/Views/YouTube/YouTubeWatchPlaybackLifecycle.swift new file mode 100644 index 000000000..3266ccbba --- /dev/null +++ b/Sources/Kaset/Views/YouTube/YouTubeWatchPlaybackLifecycle.swift @@ -0,0 +1,37 @@ +// MARK: - YouTubeWatchPlaybackLifecycle + +@MainActor +enum YouTubeWatchPlaybackLifecycle { + static func presentSurface( + video: YouTubeVideo, + player: YouTubePlayerService, + usesCookieFreeDataStore: Bool, + startAt: Double? = nil, + data: WatchNextData + ) { + if player.currentVideo?.videoId == video.videoId { + if player.surfaceLocation == .floating { + player.dockInline() + } + } else { + player.play( + video: video, + usesCookieFreeDataStore: usesCookieFreeDataStore, + startAt: startAt + ) + } + player.setUpNext(data.related) + player.setChapters(data.chapters) + player.activeInlineVideoId = video.videoId + } + + static func synchronizeLoadedData( + videoId: String, + player: YouTubePlayerService, + data: WatchNextData + ) { + guard player.currentVideo?.videoId == videoId else { return } + player.setUpNext(data.related) + player.setChapters(data.chapters) + } +} diff --git a/Sources/Kaset/Views/YouTube/YouTubeWatchView.swift b/Sources/Kaset/Views/YouTube/YouTubeWatchView.swift index 03220e403..72719813d 100644 --- a/Sources/Kaset/Views/YouTube/YouTubeWatchView.swift +++ b/Sources/Kaset/Views/YouTube/YouTubeWatchView.swift @@ -128,16 +128,17 @@ struct YouTubeWatchView: View { self.ambientStylePicker } #endif + .task { + self.startOrAdoptPlayback() + } .task(id: self.askAccountScope) { let accountScope = self.askAccountScope - self.startOrAdoptPlayback() await self.viewModel.load(accountScope: accountScope) - // Feed the related list to the player so the bar's next/previous - // buttons can skip between videos. - if self.youtubePlayer.currentVideo?.videoId == self.video.videoId { - self.youtubePlayer.setUpNext(self.viewModel.data.related) - self.youtubePlayer.setChapters(self.viewModel.data.chapters) - } + YouTubeWatchPlaybackLifecycle.synchronizeLoadedData( + videoId: self.video.videoId, + player: self.youtubePlayer, + data: self.viewModel.data + ) } .onDisappear { self.viewModel.cancel() @@ -262,20 +263,13 @@ struct YouTubeWatchView: View { /// Starts playback of this view's video, or adopts the surface if this /// video is already playing (e.g. docking back from the floating window). private func startOrAdoptPlayback(startAt: Double? = nil) { - if self.youtubePlayer.currentVideo?.videoId == self.video.videoId { - if self.youtubePlayer.surfaceLocation == .floating { - self.youtubePlayer.dockInline() - } - } else { - self.youtubePlayer.play( - video: self.video, - usesCookieFreeDataStore: self.authService.shouldUseCookieFreePlaybackDataStore, - startAt: startAt - ) - } - self.youtubePlayer.setUpNext(self.viewModel.data.related) - self.youtubePlayer.setChapters(self.viewModel.data.chapters) - self.youtubePlayer.activeInlineVideoId = self.video.videoId + YouTubeWatchPlaybackLifecycle.presentSurface( + video: self.video, + player: self.youtubePlayer, + usesCookieFreeDataStore: self.authService.shouldUseCookieFreePlaybackDataStore, + startAt: startAt, + data: self.viewModel.data + ) } // MARK: - Metadata diff --git a/Tests/KasetTests/YouTubeWatchPlaybackLifecycleTests.swift b/Tests/KasetTests/YouTubeWatchPlaybackLifecycleTests.swift new file mode 100644 index 000000000..3d4a5bf49 --- /dev/null +++ b/Tests/KasetTests/YouTubeWatchPlaybackLifecycleTests.swift @@ -0,0 +1,142 @@ +import Testing +@testable import Kaset + +@Suite("YouTube watch playback lifecycle", .serialized) +@MainActor +struct YouTubeWatchPlaybackLifecycleTests { + @Test("Initial presentation starts a new video inline") + func initialPresentationStartsInline() { + let controller = MockYouTubeWatchPlaybackController() + let player = YouTubePlayerService( + playbackController: controller, + shouldPopOutOnNavigateAway: { true } + ) + let video = MockYouTubeClient.makeVideo(videoId: "video-a") + + YouTubeWatchPlaybackLifecycle.presentSurface( + video: video, + player: player, + usesCookieFreeDataStore: false, + data: .empty + ) + + #expect(player.currentVideo?.videoId == video.videoId) + #expect(player.surfaceLocation == .inline) + #expect(player.activeInlineVideoId == video.videoId) + #expect(controller.loadedVideoIds == [video.videoId]) + } + + @Test("Initial presentation adopts and docks the matching floating video") + func initialPresentationAdoptsMatchingFloatingVideo() { + let controller = MockYouTubeWatchPlaybackController() + let player = YouTubePlayerService( + playbackController: controller, + shouldPopOutOnNavigateAway: { true } + ) + let video = MockYouTubeClient.makeVideo(videoId: "video-a") + player.play(video: video) + player.popOutToWindow() + + YouTubeWatchPlaybackLifecycle.presentSurface( + video: video, + player: player, + usesCookieFreeDataStore: false, + data: .empty + ) + + #expect(player.surfaceLocation == .inline) + #expect(player.activeInlineVideoId == video.videoId) + #expect(controller.loadedVideoIds == [video.videoId]) + } + + @Test("Account-scope refresh preserves a user-controlled floating surface") + func accountScopeRefreshPreservesFloatingSurface() { + let controller = MockYouTubeWatchPlaybackController() + let player = YouTubePlayerService( + playbackController: controller, + shouldPopOutOnNavigateAway: { true } + ) + let video = MockYouTubeClient.makeVideo(videoId: "video-a") + let related = MockYouTubeClient.makeVideo(videoId: "video-b") + let chapter = YouTubeChapter( + videoId: video.videoId, + title: "Chapter", + startTime: 10, + endTime: 20, + timeText: "0:10", + thumbnailURL: nil + ) + player.play(video: video) + player.activeInlineVideoId = video.videoId + player.popOutToWindow() + + YouTubeWatchPlaybackLifecycle.synchronizeLoadedData( + videoId: video.videoId, + player: player, + data: WatchNextData( + videoTitle: nil, + viewCountText: nil, + publishedText: nil, + channel: nil, + related: [related], + chapters: [chapter] + ) + ) + + #expect(player.surfaceLocation == .floating) + #expect(player.currentVideo?.videoId == video.videoId) + #expect(player.upNext.map(\.videoId) == [related.videoId]) + #expect(player.chapters == [chapter]) + #expect(controller.loadedVideoIds == [video.videoId]) + } + + @Test("Account-scope refresh cannot overwrite another video's companion data") + func accountScopeRefreshDoesNotOverwriteAnotherVideo() { + let controller = MockYouTubeWatchPlaybackController() + let player = YouTubePlayerService( + playbackController: controller, + shouldPopOutOnNavigateAway: { true } + ) + let currentVideo = MockYouTubeClient.makeVideo(videoId: "video-a") + let existingRelated = MockYouTubeClient.makeVideo(videoId: "video-b") + let replacementRelated = MockYouTubeClient.makeVideo(videoId: "video-c") + let existingChapter = YouTubeChapter( + videoId: currentVideo.videoId, + title: "Existing", + startTime: 0, + endTime: 10, + timeText: "0:00", + thumbnailURL: nil + ) + let replacementChapter = YouTubeChapter( + videoId: "other-video", + title: "Replacement", + startTime: 10, + endTime: 20, + timeText: "0:10", + thumbnailURL: nil + ) + player.play(video: currentVideo) + player.setUpNext([existingRelated]) + player.setChapters([existingChapter]) + player.popOutToWindow() + + YouTubeWatchPlaybackLifecycle.synchronizeLoadedData( + videoId: "other-video", + player: player, + data: WatchNextData( + videoTitle: nil, + viewCountText: nil, + publishedText: nil, + channel: nil, + related: [replacementRelated], + chapters: [replacementChapter] + ) + ) + + #expect(player.surfaceLocation == .floating) + #expect(player.upNext.map(\.videoId) == [existingRelated.videoId]) + #expect(player.chapters == [existingChapter]) + #expect(controller.loadedVideoIds == [currentVideo.videoId]) + } +} From 209d6543786302519293482fa599523d9dc542e2 Mon Sep 17 00:00:00 2001 From: Sertac Ozercan Date: Mon, 3 Aug 2026 03:52:47 -0700 Subject: [PATCH 18/18] fix(api-explorer): redact next action responses Signed-off-by: Sertac Ozercan --- Sources/APIExplorer/main.swift | 16 +++- .../APIExplorerActionRoutingTests.swift | 79 +++++++++++++++++++ 2 files changed, 92 insertions(+), 3 deletions(-) create mode 100644 Tests/KasetTests/APIExplorerActionRoutingTests.swift diff --git a/Sources/APIExplorer/main.swift b/Sources/APIExplorer/main.swift index 737e23251..870566967 100755 --- a/Sources/APIExplorer/main.swift +++ b/Sources/APIExplorer/main.swift @@ -2164,6 +2164,10 @@ func requiresPrivateBodySource(_ endpoint: String) -> Bool { ["get_answer", "get_panel", "streaming_panel"].contains(endpoint) } +func requiresRedactedWireInspection(_ endpoint: String) -> Bool { + endpoint == "next" || requiresPrivateBodySource(endpoint) +} + func exploreWireAction( _ endpoint: String, bodyJson: String, outputFile: String? = nil ) async { @@ -3674,9 +3678,15 @@ func runMain() async { inlineBody: filteredArgs.count >= 3 ? filteredArgs[2] : nil, bodyFile: bodyFile ) - await exploreAction( - endpoint, bodyJson: bodyJson, verbose: verbose, outputFile: outputFile - ) + if requiresRedactedWireInspection(endpoint) { + await exploreWireAction( + endpoint, bodyJson: bodyJson, outputFile: outputFile + ) + } else { + await exploreAction( + endpoint, bodyJson: bodyJson, verbose: verbose, outputFile: outputFile + ) + } } catch { print("❌ \(error.localizedDescription)") } diff --git a/Tests/KasetTests/APIExplorerActionRoutingTests.swift b/Tests/KasetTests/APIExplorerActionRoutingTests.swift new file mode 100644 index 000000000..8563598f1 --- /dev/null +++ b/Tests/KasetTests/APIExplorerActionRoutingTests.swift @@ -0,0 +1,79 @@ +import Foundation +import Testing + +@Suite("API Explorer action routing", .tags(.api)) +struct APIExplorerActionRoutingTests { + @Test("Next uses redacted wire inspection without requiring a private request body") + func nextUsesRedactedWireInspection() throws { + let source = try Self.apiExplorerSource() + let privateBodyRouting = try Self.section( + in: source, + startingWith: "func requiresPrivateBodySource", + endingBefore: "func requiresRedactedWireInspection" + ) + let redactedRouting = try Self.section( + in: source, + startingWith: "func requiresRedactedWireInspection", + endingBefore: "func exploreWireAction" + ) + let actionDispatch = try Self.section( + in: source, + startingWith: "case \"action\":", + endingBefore: "case \"wire-action\":" + ) + + #expect(!privateBodyRouting.contains("\"next\"")) + #expect(redactedRouting.contains("\"next\"")) + #expect(actionDispatch.contains("requiresRedactedWireInspection(endpoint)")) + #expect(actionDispatch.contains("await exploreWireAction(")) + } + + @Test("Non-redacted actions retain the standard verbose inspector") + func ordinaryActionsRetainStandardInspector() throws { + let source = try Self.apiExplorerSource() + let actionDispatch = try Self.section( + in: source, + startingWith: "case \"action\":", + endingBefore: "case \"wire-action\":" + ) + + #expect(actionDispatch.contains("await exploreAction(")) + #expect(actionDispatch.contains("verbose: verbose")) + #expect(actionDispatch.contains("outputFile: outputFile")) + } + + @Test("Redacted wire inspection never prints decoded response values") + func redactedInspectorDoesNotPrintDecodedValues() throws { + let source = try Self.apiExplorerSource() + let inspector = try Self.section( + in: source, + startingWith: "func exploreWireAction", + endingBefore: "private func auditSearchFilter" + ) + + #expect(inspector.contains("wireResponseAuditSummary(")) + #expect(inspector.contains("Raw response values stay hidden")) + #expect(!inspector.contains("analyzeResponse(")) + #expect(!inspector.contains("Raw response (pretty-printed)")) + let rawPrintCall = "print" + "(prettyString)" + #expect(!inspector.contains(rawPrintCall)) + } + + private static func apiExplorerSource() throws -> String { + let sourcePath = #filePath.replacingOccurrences( + of: "Tests/KasetTests/APIExplorerActionRoutingTests.swift", + with: "Sources/APIExplorer/main.swift" + ) + return try String(contentsOfFile: sourcePath, encoding: .utf8) + } + + private static func section( + in source: String, + startingWith startMarker: String, + endingBefore endMarker: String + ) throws -> Substring { + let start = try #require(source.range(of: startMarker)?.lowerBound) + let end = try #require(source.range(of: endMarker, range: start ..< source.endIndex)?.lowerBound) + return source[start ..< end] + } +}