diff --git a/src/app/[lang]/install-app/page.tsx b/src/app/[lang]/install-app/page.tsx index 1cadfc9f..8494b31f 100644 --- a/src/app/[lang]/install-app/page.tsx +++ b/src/app/[lang]/install-app/page.tsx @@ -14,6 +14,7 @@ export default async function InstallAppPage({ params }: { params: Promise<{ lan const copy = getInstallPageCopy(locale) const allToolsLabel = t.common.all_tools const trustCenterLabel = t.pages.trust_center_title + const localDataControlsLabel = t.common.local_data_controls.title const offlineMatrixTitle = t.pages.trust_center_offline_matrix_title const offlineMatrixDescription = t.pages.trust_center_offline_matrix_install_desc const offlineMatrixLink = t.pages.trust_center_offline_matrix_link @@ -24,6 +25,7 @@ export default async function InstallAppPage({ params }: { params: Promise<{ lan copy={copy} allToolsLabel={allToolsLabel} trustCenterLabel={trustCenterLabel} + localDataControlsLabel={localDataControlsLabel} offlineMatrixTitle={offlineMatrixTitle} offlineMatrixDescription={offlineMatrixDescription} offlineMatrixLink={offlineMatrixLink} diff --git a/src/app/[lang]/privacy/page.tsx b/src/app/[lang]/privacy/page.tsx index 801f4034..b8487ae5 100644 --- a/src/app/[lang]/privacy/page.tsx +++ b/src/app/[lang]/privacy/page.tsx @@ -38,6 +38,12 @@ export default function PrivacyPage() { > {p.trust_center_title} + + {t.common.local_data_controls.title} +
diff --git a/src/app/[lang]/trust-center/page.tsx b/src/app/[lang]/trust-center/page.tsx index b510c38b..f738c1ca 100644 --- a/src/app/[lang]/trust-center/page.tsx +++ b/src/app/[lang]/trust-center/page.tsx @@ -1,6 +1,6 @@ import Link from "next/link" import { notFound } from "next/navigation" -import { CheckCircle2, ExternalLink, FileText, Github, LockKeyhole, Network, ShieldCheck, WifiOff } from "lucide-react" +import { CheckCircle2, Database, ExternalLink, FileText, Github, LockKeyhole, Network, ShieldCheck, WifiOff } from "lucide-react" import { isValidLocale, requireTranslationValue } from "@/core/i18n/i18n" import { getTranslation } from "@/core/i18n/translations/catalog" import { TOOL_REGISTRY } from "@/core/registry" @@ -174,6 +174,13 @@ export default async function TrustCenterPage({
diff --git a/src/components/layout/server-footer.tsx b/src/components/layout/server-footer.tsx index 42b882fd..93ade2d2 100644 --- a/src/components/layout/server-footer.tsx +++ b/src/components/layout/server-footer.tsx @@ -20,6 +20,7 @@ export function ServerFooter({ const pageLinks = [ { key: "install-app", slug: "install-app", label: requireTranslationValue(translations.common.install_app_label, "common.install_app_label") }, + { key: "local-data-controls", slug: "privacy#local-data-controls", label: requireTranslationValue(translations.common.local_data_controls.title, "common.local_data_controls.title") }, { key: "about", slug: "about", label: requireTranslationValue(translations.pages.about_title, "pages.about_title") }, { key: "privacy", slug: "privacy", label: requireTranslationValue(translations.pages.privacy_title, "pages.privacy_title") }, { key: "trust-center", slug: "trust-center", label: requireTranslationValue(translations.pages.trust_center_title, "pages.trust_center_title") }, diff --git a/src/core/analytics/preferences.ts b/src/core/analytics/preferences.ts index bc28ab0b..b72e0bff 100644 --- a/src/core/analytics/preferences.ts +++ b/src/core/analytics/preferences.ts @@ -4,6 +4,10 @@ export type AnalyticsPreference = "default" | "opted_out" const ANALYTICS_OPT_OUT_KEY = "byteflow:analytics:opt-out" +export function getAnalyticsOptOutStorageKey(): string { + return ANALYTICS_OPT_OUT_KEY +} + export function getAnalyticsPreference(): AnalyticsPreference { return readStorageString(ANALYTICS_OPT_OUT_KEY) === "1" ? "opted_out" : "default" } diff --git a/src/core/i18n/translations/de.json b/src/core/i18n/translations/de.json index b89b00eb..36dbba50 100644 --- a/src/core/i18n/translations/de.json +++ b/src/core/i18n/translations/de.json @@ -183,7 +183,36 @@ "analytics_never_title": "Nie Analytics-Felder", "analytics_never_fields": "Tool-Eingaben, Ausgaben, Payloads, Tokens, JWTs, Geheimnisse, Dateinamen, Dateiinhalte, Bildinhalte, Logtexte, Hashwerte, Suchtext, vollstaendige URLs, Nutzer-IDs und Sitzungs-IDs sind verboten.", "analytics_privacy_link": "Datenschutzrichtlinie", - "analytics_trust_link": "Vertrauenszentrum" + "analytics_trust_link": "Vertrauenszentrum", + "no_payloads_notice": "Eingaben, Ausgaben, Dateien, Logs, Token und volle Adressen werden nicht gespeichert. Hier pruefen und loeschen Sie sicheren lokalen Zustand.", + "count_label": "{count} gespeichert", + "favorites_title": "Favoriten", + "favorites_description": "In diesem Browser gespeicherte Favoriten-IDs.", + "clear_favorites_button": "Favoriten loeschen", + "favorites_cleared_message": "{count} Favoriten-Werkzeuge geloescht.", + "recent_title": "Zuletzt verwendete Werkzeuge", + "recent_description": "Lokal gespeicherte letzte Werkzeug-IDs und Zeiten.", + "clear_recent_button": "Zuletzt verwendete Werkzeuge loeschen", + "recent_cleared_message": "{count} zuletzt verwendete Werkzeuge geloescht.", + "preferences_title": "Einstellungen und sichere Optionen", + "preferences_description": "Theme, Sprache, App-Hinweise, Seitenleiste und nicht sensible Werkzeugoptionen.", + "clear_preferences_button": "Einstellungen loeschen", + "preferences_cleared_message": "{count} Einstellungswerte geloescht.", + "recipes_title": "Gespeicherte Pipeline-Rezepte", + "recipes_description": "Pipeline-Rezepte nur mit Struktur. Laufzeit-Eingaben und Ausgaben sind ausgeschlossen.", + "clear_recipes_button": "Gespeicherte Rezepte loeschen", + "recipes_cleared_message": "{count} gespeicherte Rezepte geloescht.", + "recipes_unavailable_message": "Rezeptspeicher ist nicht verfuegbar.", + "cache_title": "Gecachte App-Dateien", + "cache_description": "App-Shell, Assets, Icons und Werkzeug-Code. Werkzeugdaten sind nicht enthalten.", + "clear_cache_button": "Gecachte App-Dateien loeschen", + "cache_cleared_message": "Gecachte App-Dateien wurden geloescht.", + "cache_none_message": "Keine gecachten App-Dateien gefunden.", + "cache_unavailable_message": "Cache-Loeschen ist nicht verfuegbar. Nutzen Sie Browser-Site-Daten.", + "browser_settings_title": "Browser- und App-Kontrollen", + "browser_settings_description": "Einige Browser verwalten App-Speicher und Site-Daten in Einstellungen. Nutzen Sie Installations- und Pruefschritte.", + "install_app_link": "Cache-Kontrollen fuer die installierte App", + "verification_link": "Pruefschritte in Entwicklerwerkzeugen" }, "privacy_badge": { "label": "Browser-lokal", diff --git a/src/core/i18n/translations/en.json b/src/core/i18n/translations/en.json index 72aae53d..43833122 100644 --- a/src/core/i18n/translations/en.json +++ b/src/core/i18n/translations/en.json @@ -187,7 +187,36 @@ "analytics_never_title": "Never analytics fields", "analytics_never_fields": "Tool inputs, outputs, payloads, tokens, JWTs, secrets, file names, file contents, image contents, log bodies, hash values, search text, full URLs, user IDs, and session IDs are forbidden.", "analytics_privacy_link": "Privacy Policy", - "analytics_trust_link": "Trust Center" + "analytics_trust_link": "Trust Center", + "no_payloads_notice": "Tool input, output, files, logs, tokens, and full URLs are not stored. Inspect and clear safe local state here.", + "count_label": "{count} saved", + "favorites_title": "Favorites", + "favorites_description": "Favorite tool IDs saved in this browser.", + "clear_favorites_button": "Clear favorites", + "favorites_cleared_message": "Cleared {count} favorite tools.", + "recent_title": "Recent tools", + "recent_description": "Recent tool IDs and timestamps saved locally.", + "clear_recent_button": "Clear recent tools", + "recent_cleared_message": "Cleared {count} recent tools.", + "preferences_title": "Safe preferences", + "preferences_description": "Theme, language, PWA prompts, sidebar state, and safe tool options.", + "clear_preferences_button": "Clear preferences", + "preferences_cleared_message": "Cleared {count} preference items.", + "recipes_title": "Saved Pipeline recipes", + "recipes_description": "Structure-only Pipeline recipes. Runtime inputs and outputs are excluded.", + "clear_recipes_button": "Delete saved recipes", + "recipes_cleared_message": "Deleted {count} saved recipes.", + "recipes_unavailable_message": "Recipe storage is unavailable.", + "cache_title": "Cached app files", + "cache_description": "PWA shell, assets, icons, and tool chunks. Tool payloads are excluded.", + "clear_cache_button": "Clear cached app files", + "cache_cleared_message": "Cached app files cleared.", + "cache_none_message": "No cached app files were found.", + "cache_unavailable_message": "Cache clearing is unavailable. Use browser site-data or app-storage settings.", + "browser_settings_title": "Browser controls", + "browser_settings_description": "Some browsers manage app storage and site data in settings. See Install App and Trust Center verification.", + "install_app_link": "Install App cache controls", + "verification_link": "DevTools verification steps" }, "privacy_badge": { "label": "Browser-local", diff --git a/src/core/i18n/translations/fr.json b/src/core/i18n/translations/fr.json index 67a9d20e..8914d9b0 100644 --- a/src/core/i18n/translations/fr.json +++ b/src/core/i18n/translations/fr.json @@ -187,7 +187,36 @@ "analytics_never_title": "Jamais dans les analytics", "analytics_never_fields": "Entrees doutils, sorties, payloads, tokens, JWT, secrets, noms de fichiers, contenus de fichiers, contenus dimages, corps de logs, hashes, texte de recherche, URL completes, ID utilisateur et ID de session sont interdits.", "analytics_privacy_link": "Politique de confidentialite", - "analytics_trust_link": "Centre de confiance" + "analytics_trust_link": "Centre de confiance", + "no_payloads_notice": "Entrees, sorties, fichiers, journaux, jetons et adresses completes ne sont pas stockes. Verifiez et supprimez l etat local sur ici.", + "count_label": "{count} enregistres", + "favorites_title": "Favoris", + "favorites_description": "Identifiants de favoris enregistres dans ce navigateur.", + "clear_favorites_button": "Effacer les favoris", + "favorites_cleared_message": "{count} outils favoris effaces.", + "recent_title": "Outils recents", + "recent_description": "Identifiants et dates recents stockes localement.", + "clear_recent_button": "Effacer les outils recents", + "recent_cleared_message": "{count} outils recents effaces.", + "preferences_title": "Preferences et reglages surs", + "preferences_description": "Theme, langue, invites d app, barre laterale et options non sensibles.", + "clear_preferences_button": "Effacer les preferences", + "preferences_cleared_message": "{count} preferences effacees.", + "recipes_title": "Recettes de pipeline enregistrees", + "recipes_description": "Recettes de pipeline structurelles. Les entrees et sorties d execution sont exclues.", + "clear_recipes_button": "Supprimer les recettes enregistrees", + "recipes_cleared_message": "{count} recettes enregistrees supprimees.", + "recipes_unavailable_message": "Le stockage des recettes est indisponible.", + "cache_title": "Fichiers d app en cache", + "cache_description": "Shell d app, ressources, icones et code d outils. Les donnees traitees ne sont pas incluses.", + "clear_cache_button": "Effacer les fichiers d app en cache", + "cache_cleared_message": "Fichiers d app en cache effaces.", + "cache_none_message": "Aucun fichier d app en cache trouve.", + "cache_unavailable_message": "L effacement du cache est indisponible. Utilisez les reglages de donnees de site.", + "browser_settings_title": "Controles du navigateur et de l app", + "browser_settings_description": "Certains navigateurs gerent stockage d app et donnees de site dans les reglages. Consultez installation et verification.", + "install_app_link": "Controles du cache de l app installee", + "verification_link": "Etapes de verification dans les outils de developpement" }, "privacy_badge": { "label": "Local au navigateur", diff --git a/src/core/i18n/translations/ja.json b/src/core/i18n/translations/ja.json index 3a241ce9..fac525a4 100644 --- a/src/core/i18n/translations/ja.json +++ b/src/core/i18n/translations/ja.json @@ -187,7 +187,36 @@ "analytics_never_title": "分析に含めないフィールド", "analytics_never_fields": "ツール入力、出力、payload、token、JWT、secret、ファイル名、ファイル内容、画像内容、ログ本文、ハッシュ値、検索テキスト、完全な URL、ユーザー ID、セッション ID は禁止です。", "analytics_privacy_link": "プライバシーポリシー", - "analytics_trust_link": "信頼センター" + "analytics_trust_link": "信頼センター", + "no_payloads_notice": "入力、出力、ファイル、ログ、トークン、完全なアドレスは保存しません。安全なローカル状態だけ確認、削除できます。", + "count_label": "{count} 件保存済み", + "favorites_title": "お気に入り", + "favorites_description": "このブラウザーに保存されたお気に入りツールの識別子です。", + "clear_favorites_button": "お気に入りを削除", + "favorites_cleared_message": "{count} 件のお気に入りツールを削除しました。", + "recent_title": "最近使ったツール", + "recent_description": "ローカル保存された最近のツール識別子と時刻です。", + "clear_recent_button": "最近使ったツールを削除", + "recent_cleared_message": "{count} 件の最近使ったツールを削除しました。", + "preferences_title": "設定と安全なオプション", + "preferences_description": "テーマ、言語、アプリ案内、サイドバー、機密ではないツール設定です。", + "clear_preferences_button": "設定を削除", + "preferences_cleared_message": "{count} 件の設定を削除しました。", + "recipes_title": "保存済みパイプライン手順", + "recipes_description": "構造だけのパイプライン手順です。実行時の入力と出力は除外されます。", + "clear_recipes_button": "保存済み手順を削除", + "recipes_cleared_message": "{count} 件の保存済み手順を削除しました。", + "recipes_unavailable_message": "手順の保存を利用できません。", + "cache_title": "キャッシュ済みアプリファイル", + "cache_description": "アプリ外枠、素材、アイコン、ツールコードです。処理内容は含みません。", + "clear_cache_button": "キャッシュ済みアプリファイルを削除", + "cache_cleared_message": "キャッシュ済みアプリファイルを削除しました。", + "cache_none_message": "キャッシュ済みアプリファイルはありません。", + "cache_unavailable_message": "キャッシュ削除を利用できません。ブラウザーのサイトデータ設定を使ってください。", + "browser_settings_title": "ブラウザーとアプリの操作", + "browser_settings_description": "一部のブラウザーはアプリ保存領域やサイトデータを設定で管理できます。案内ページと検証手順を確認してください。", + "install_app_link": "インストール済みアプリのキャッシュ操作", + "verification_link": "開発者ツールでの検証手順" }, "privacy_badge": { "label": "ブラウザローカル", diff --git a/src/core/i18n/translations/ko.json b/src/core/i18n/translations/ko.json index f88cbe10..8c4d658f 100644 --- a/src/core/i18n/translations/ko.json +++ b/src/core/i18n/translations/ko.json @@ -187,7 +187,36 @@ "analytics_never_title": "분석에 포함하지 않는 필드", "analytics_never_fields": "도구 입력, 출력, payload, token, JWT, secret, 파일 이름, 파일 내용, 이미지 내용, 로그 본문, 해시값, 검색 텍스트, 전체 URL, 사용자 ID, 세션 ID는 금지됩니다.", "analytics_privacy_link": "개인정보 처리방침", - "analytics_trust_link": "신뢰 센터" + "analytics_trust_link": "신뢰 센터", + "no_payloads_notice": "입력, 출력, 파일, 로그, 토큰, 전체 주소는 저장하지 않습니다. 안전한 로컬 상태만 확인하고 지울 수 있습니다.", + "count_label": "{count}개 저장됨", + "favorites_title": "즐겨찾기", + "favorites_description": "이 브라우저에 저장된 즐겨찾기 도구 식별자입니다.", + "clear_favorites_button": "즐겨찾기 지우기", + "favorites_cleared_message": "즐겨찾기 도구 {count}개를 지웠습니다.", + "recent_title": "최근 사용 도구", + "recent_description": "로컬에 저장된 최근 도구 식별자와 시간입니다.", + "clear_recent_button": "최근 사용 도구 지우기", + "recent_cleared_message": "최근 사용 도구 {count}개를 지웠습니다.", + "preferences_title": "환경설정과 안전 설정", + "preferences_description": "테마, 언어, 앱 안내, 사이드바, 민감하지 않은 도구 옵션입니다.", + "clear_preferences_button": "환경설정 지우기", + "preferences_cleared_message": "환경설정 {count}개를 지웠습니다.", + "recipes_title": "저장된 파이프라인 레시피", + "recipes_description": "구조만 담은 파이프라인 레시피입니다. 실행 입력과 출력은 제외됩니다.", + "clear_recipes_button": "저장된 레시피 삭제", + "recipes_cleared_message": "저장된 레시피 {count}개를 삭제했습니다.", + "recipes_unavailable_message": "레시피 저장소를 사용할 수 없습니다.", + "cache_title": "캐시된 앱 파일", + "cache_description": "앱 셸, 자산, 아이콘, 도구 코드입니다. 처리 내용은 포함하지 않습니다.", + "clear_cache_button": "캐시된 앱 파일 지우기", + "cache_cleared_message": "캐시된 앱 파일을 지웠습니다.", + "cache_none_message": "캐시된 앱 파일이 없습니다.", + "cache_unavailable_message": "캐시 지우기를 사용할 수 없습니다. 브라우저 사이트 데이터 설정을 사용하세요.", + "browser_settings_title": "브라우저와 앱 제어", + "browser_settings_description": "일부 브라우저는 앱 저장소와 사이트 데이터를 설정에서 관리합니다. 설치 안내와 검증 단계를 확인하세요.", + "install_app_link": "설치 앱 캐시 제어", + "verification_link": "개발자 도구 검증 단계" }, "privacy_badge": { "label": "브라우저 로컬", diff --git a/src/core/i18n/translations/zh-CN.json b/src/core/i18n/translations/zh-CN.json index 51e558cb..734bfd8d 100644 --- a/src/core/i18n/translations/zh-CN.json +++ b/src/core/i18n/translations/zh-CN.json @@ -187,7 +187,36 @@ "analytics_never_title": "永不进入分析的字段", "analytics_never_fields": "工具输入、输出、payload、token、JWT、密钥、文件名、文件内容、图片内容、日志正文、哈希值、搜索文本、完整 URL、用户 ID 和会话 ID 均被禁止。", "analytics_privacy_link": "隐私政策", - "analytics_trust_link": "信任中心" + "analytics_trust_link": "信任中心", + "no_payloads_notice": "工具输入、输出、文件、日志、token 和完整 URL 不会存储。可在这里检查并清理安全本地状态。", + "count_label": "已保存 {count}", + "favorites_title": "收藏", + "favorites_description": "保存在当前浏览器中的收藏工具 ID。", + "clear_favorites_button": "清除收藏", + "favorites_cleared_message": "已清除 {count} 个收藏工具。", + "recent_title": "最近使用的工具", + "recent_description": "本地保存的最近工具 ID 和时间戳。", + "clear_recent_button": "清除最近使用记录", + "recent_cleared_message": "已清除 {count} 个最近使用工具。", + "preferences_title": "偏好和安全设置", + "preferences_description": "主题、语言、PWA 提示、侧边栏状态和非敏感工具选项。", + "clear_preferences_button": "清除偏好设置", + "preferences_cleared_message": "已清除 {count} 个偏好项。", + "recipes_title": "已保存 Pipeline recipe", + "recipes_description": "IndexedDB 中仅含结构的 Pipeline recipe。运行时输入和输出会被排除。", + "clear_recipes_button": "删除已保存 recipe", + "recipes_cleared_message": "已删除 {count} 个已保存 recipe。", + "recipes_unavailable_message": "Recipe 存储不可用。", + "cache_title": "已缓存的应用文件", + "cache_description": "PWA 应用外壳、资源、图标和工具代码块。不包含工具 payload。", + "clear_cache_button": "清除已缓存应用文件", + "cache_cleared_message": "已清除缓存的应用文件。", + "cache_none_message": "未找到缓存的应用文件。", + "cache_unavailable_message": "缓存清理不可用。请使用浏览器站点数据或应用存储设置。", + "browser_settings_title": "浏览器和 PWA 控制", + "browser_settings_description": "部分浏览器可在设置中管理应用存储和站点数据。可查看安装应用指引和信任中心验证步骤。", + "install_app_link": "安装应用缓存控制", + "verification_link": "DevTools 验证步骤" }, "privacy_badge": { "label": "浏览器本地", diff --git a/src/core/i18n/translations/zh-TW.json b/src/core/i18n/translations/zh-TW.json index 4f125e37..830ffd52 100644 --- a/src/core/i18n/translations/zh-TW.json +++ b/src/core/i18n/translations/zh-TW.json @@ -187,7 +187,36 @@ "analytics_never_title": "永不進入分析的欄位", "analytics_never_fields": "工具輸入、輸出、payload、token、JWT、密鑰、檔名、檔案內容、圖片內容、日誌本文、雜湊值、搜尋文字、完整 URL、使用者 ID 和工作階段 ID 均被禁止。", "analytics_privacy_link": "隱私權政策", - "analytics_trust_link": "信任中心" + "analytics_trust_link": "信任中心", + "no_payloads_notice": "工具輸入、輸出、檔案、日誌、token 和完整 URL 不會儲存。可在這裡檢查並清除安全本機狀態。", + "count_label": "已保存 {count}", + "favorites_title": "收藏", + "favorites_description": "保存在目前瀏覽器中的收藏工具 ID。", + "clear_favorites_button": "清除收藏", + "favorites_cleared_message": "已清除 {count} 個收藏工具。", + "recent_title": "最近使用的工具", + "recent_description": "本機保存的最近工具 ID 和時間戳。", + "clear_recent_button": "清除最近使用紀錄", + "recent_cleared_message": "已清除 {count} 個最近使用工具。", + "preferences_title": "偏好和安全設定", + "preferences_description": "主題、語言、PWA 提示、側邊欄狀態和非敏感工具選項。", + "clear_preferences_button": "清除偏好設定", + "preferences_cleared_message": "已清除 {count} 個偏好項。", + "recipes_title": "已保存 Pipeline recipe", + "recipes_description": "IndexedDB 中僅含結構的 Pipeline recipe。執行時輸入和輸出會被排除。", + "clear_recipes_button": "刪除已保存 recipe", + "recipes_cleared_message": "已刪除 {count} 個已保存 recipe。", + "recipes_unavailable_message": "Recipe 儲存不可用。", + "cache_title": "已快取的應用程式檔案", + "cache_description": "PWA 應用程式外殼、資源、圖示和工具程式碼區塊。不包含工具 payload。", + "clear_cache_button": "清除已快取應用程式檔案", + "cache_cleared_message": "已清除快取的應用程式檔案。", + "cache_none_message": "未找到快取的應用程式檔案。", + "cache_unavailable_message": "快取清理不可用。請使用瀏覽器網站資料或應用程式儲存設定。", + "browser_settings_title": "瀏覽器和 PWA 控制", + "browser_settings_description": "部分瀏覽器可在設定中管理應用程式儲存和網站資料。可查看安裝應用程式指引和信任中心驗證步驟。", + "install_app_link": "安裝應用程式快取控制", + "verification_link": "DevTools 驗證步驟" }, "privacy_badge": { "label": "瀏覽器本地", diff --git a/src/core/storage/pwa-cache-controls.ts b/src/core/storage/pwa-cache-controls.ts new file mode 100644 index 00000000..cdf6e0a4 --- /dev/null +++ b/src/core/storage/pwa-cache-controls.ts @@ -0,0 +1,47 @@ +"use client" + +const BYTEFLOW_CACHE_PREFIX = "byteflow-" +const SERVICE_WORKER_CACHE_CLEAR_TIMEOUT_MS = 1500 + +export async function deleteByteflowCacheBuckets(): Promise { + if (typeof window === "undefined" || !("caches" in window)) { + throw new Error("CacheStorage unavailable") + } + + const cacheKeys = await window.caches.keys() + const byteflowCacheKeys = cacheKeys.filter((key) => key.startsWith(BYTEFLOW_CACHE_PREFIX)) + await Promise.all(byteflowCacheKeys.map((key) => window.caches.delete(key))) + return byteflowCacheKeys.length +} + +export async function requestServiceWorkerCacheClear(): Promise { + if (typeof navigator === "undefined" || !("serviceWorker" in navigator)) return + const controller = navigator.serviceWorker.controller + if (!controller) return + + await new Promise((resolve) => { + const cleanup = () => { + window.clearTimeout(timeoutId) + navigator.serviceWorker.removeEventListener("message", handleMessage) + } + const handleMessage = (event: MessageEvent) => { + if (event.data?.type !== "BYTEFLOW_CACHES_CLEARED") return + cleanup() + resolve() + } + const timeoutId = window.setTimeout(() => { + cleanup() + resolve() + }, SERVICE_WORKER_CACHE_CLEAR_TIMEOUT_MS) + + navigator.serviceWorker.addEventListener("message", handleMessage) + controller.postMessage({ type: "CLEAR_BYTEFLOW_CACHES" }) + }) +} + +export async function clearByteflowPwaCaches(): Promise { + const firstPassCount = await deleteByteflowCacheBuckets() + await requestServiceWorkerCacheClear() + const secondPassCount = await deleteByteflowCacheBuckets() + return firstPassCount + secondPassCount +} diff --git a/src/core/storage/tool-discovery-state.ts b/src/core/storage/tool-discovery-state.ts index 30a9112e..68471ab3 100644 --- a/src/core/storage/tool-discovery-state.ts +++ b/src/core/storage/tool-discovery-state.ts @@ -123,3 +123,11 @@ export function clearHistory() { writeStorageJson(RECENT_TOOL_KEYS_STORAGE_KEY, []) emitUpdateEvent() } + +export function getFavoriteToolStorageKey(): string { + return FAVORITE_TOOL_KEYS_STORAGE_KEY +} + +export function getRecentToolStorageKey(): string { + return RECENT_TOOL_KEYS_STORAGE_KEY +} diff --git a/src/features/install-app/components/install-app-client.tsx b/src/features/install-app/components/install-app-client.tsx index 32d564b0..5e859762 100644 --- a/src/features/install-app/components/install-app-client.tsx +++ b/src/features/install-app/components/install-app-client.tsx @@ -9,6 +9,7 @@ import { Button } from "@/components/ui/button" import { getAllToolsHref } from "@/core/routing/all-tools-route" import type { Locale } from "@/core/i18n/i18n" import { JsonLdScript } from "@/core/seo/components/json-ld-script" +import { clearByteflowPwaCaches } from "@/core/storage/pwa-cache-controls" import type { GuidePlatform, InstallPageCopy } from "@/core/utils/install-app-copy" type BeforeInstallPromptEvent = Event & { @@ -28,51 +29,13 @@ const BENEFIT_ICON_BY_KEY = { works_offline: WifiOff, local_first: Shield, } as const -const BYTEFLOW_CACHE_PREFIX = "byteflow-" -const SERVICE_WORKER_CACHE_CLEAR_TIMEOUT_MS = 1500 - -async function deleteByteflowCacheBuckets() { - if (typeof window === "undefined" || !("caches" in window)) { - throw new Error("CacheStorage unavailable") - } - const cacheKeys = await window.caches.keys() - await Promise.all( - cacheKeys - .filter((key) => key.startsWith(BYTEFLOW_CACHE_PREFIX)) - .map((key) => window.caches.delete(key)), - ) -} - -async function requestServiceWorkerCacheClear() { - if (typeof navigator === "undefined" || !("serviceWorker" in navigator)) return - const controller = navigator.serviceWorker.controller - if (!controller) return - - await new Promise((resolve) => { - const cleanup = () => { - window.clearTimeout(timeoutId) - navigator.serviceWorker.removeEventListener("message", handleMessage) - } - const handleMessage = (event: MessageEvent) => { - if (event.data?.type !== "BYTEFLOW_CACHES_CLEARED") return - cleanup() - resolve() - } - const timeoutId = window.setTimeout(() => { - cleanup() - resolve() - }, SERVICE_WORKER_CACHE_CLEAR_TIMEOUT_MS) - - navigator.serviceWorker.addEventListener("message", handleMessage) - controller.postMessage({ type: "CLEAR_BYTEFLOW_CACHES" }) - }) -} type InstallAppClientProps = { locale: Locale copy: InstallPageCopy allToolsLabel: string trustCenterLabel: string + localDataControlsLabel: string offlineMatrixTitle: string offlineMatrixDescription: string offlineMatrixLink: string @@ -83,6 +46,7 @@ export function InstallAppClient({ copy, allToolsLabel, trustCenterLabel, + localDataControlsLabel, offlineMatrixTitle, offlineMatrixDescription, offlineMatrixLink, @@ -169,9 +133,7 @@ export function InstallAppClient({ const handleClearCachedAppFiles = async () => { setCacheClearPending(true) try { - await deleteByteflowCacheBuckets() - await requestServiceWorkerCacheClear() - await deleteByteflowCacheBuckets() + await clearByteflowPwaCaches() setCacheClearStatus("success") } catch { setCacheClearStatus("unavailable") @@ -351,6 +313,12 @@ export function InstallAppClient({ {trustCenterLabel} + {message ?

{message}

: null} +
+ {[ + { + key: "favorites" as const, + icon: Heart, + title: requireTranslationValue(labels.favorites_title, "common.local_data_controls.favorites_title"), + count: summary.favorites, + description: requireTranslationValue(labels.favorites_description, "common.local_data_controls.favorites_description"), + button: requireTranslationValue(labels.clear_favorites_button, "common.local_data_controls.clear_favorites_button"), + onClick: clearFavorites, + disabled: summary.favorites === 0, + }, + { + key: "recent" as const, + icon: History, + title: requireTranslationValue(labels.recent_title, "common.local_data_controls.recent_title"), + count: summary.recentTools, + description: requireTranslationValue(labels.recent_description, "common.local_data_controls.recent_description"), + button: requireTranslationValue(labels.clear_recent_button, "common.local_data_controls.clear_recent_button"), + onClick: clearRecent, + disabled: summary.recentTools === 0, + }, + { + key: "preferences" as const, + icon: Database, + title: requireTranslationValue(labels.preferences_title, "common.local_data_controls.preferences_title"), + count: summary.preferences, + description: requireTranslationValue(labels.preferences_description, "common.local_data_controls.preferences_description"), + button: requireTranslationValue(labels.clear_preferences_button, "common.local_data_controls.clear_preferences_button"), + onClick: clearPreferences, + disabled: summary.preferences === 0, + }, + { + key: "recipes" as const, + icon: Workflow, + title: requireTranslationValue(labels.recipes_title, "common.local_data_controls.recipes_title"), + count: summary.savedRecipes, + description: summary.recipeStoreAvailable + ? requireTranslationValue(labels.recipes_description, "common.local_data_controls.recipes_description") + : requireTranslationValue(labels.recipes_unavailable_message, "common.local_data_controls.recipes_unavailable_message"), + button: requireTranslationValue(labels.clear_recipes_button, "common.local_data_controls.clear_recipes_button"), + onClick: clearRecipes, + disabled: !summary.recipeStoreAvailable || (summary.savedRecipes ?? 0) === 0, + }, + { + key: "cache" as const, + icon: WifiOff, + title: requireTranslationValue(labels.cache_title, "common.local_data_controls.cache_title"), + count: null, + description: requireTranslationValue(labels.cache_description, "common.local_data_controls.cache_description"), + button: requireTranslationValue(labels.clear_cache_button, "common.local_data_controls.clear_cache_button"), + onClick: clearCache, + disabled: false, + }, + ].map((item) => { + const Icon = item.icon + const isPending = pendingAction === item.key + return ( +
+
+
+

+

+

{item.description}

+
+ {typeof item.count === "number" ? ( + + {formatCount(requireTranslationValue(labels.count_label, "common.local_data_controls.count_label"), item.count)} + + ) : null} +
+ +
+ ) + })} +
+
+

{requireTranslationValue(labels.browser_settings_title, "common.local_data_controls.browser_settings_title")}

+

+ {requireTranslationValue(labels.browser_settings_description, "common.local_data_controls.browser_settings_description")} +

+
+ + {requireTranslationValue(labels.install_app_link, "common.local_data_controls.install_app_link")} + + + {requireTranslationValue(labels.verification_link, "common.local_data_controls.verification_link")} + +
+

diff --git a/src/lib/sitemap-lastmod.json b/src/lib/sitemap-lastmod.json index 920de947..f887abb3 100644 --- a/src/lib/sitemap-lastmod.json +++ b/src/lib/sitemap-lastmod.json @@ -283,13 +283,13 @@ "fr": "2026-06-14T00:00:00.000Z" }, "install-app": { - "en": "2026-06-24T00:00:00.000Z", - "zh-CN": "2026-06-24T00:00:00.000Z", - "zh-TW": "2026-06-24T00:00:00.000Z", - "ja": "2026-06-24T00:00:00.000Z", - "ko": "2026-06-24T00:00:00.000Z", - "de": "2026-06-24T00:00:00.000Z", - "fr": "2026-06-24T00:00:00.000Z" + "en": "2026-06-26T00:00:00.000Z", + "zh-CN": "2026-06-26T00:00:00.000Z", + "zh-TW": "2026-06-26T00:00:00.000Z", + "ja": "2026-06-26T00:00:00.000Z", + "ko": "2026-06-26T00:00:00.000Z", + "de": "2026-06-26T00:00:00.000Z", + "fr": "2026-06-26T00:00:00.000Z" }, "workflows": { "en": "2026-06-22T00:00:00.000Z", diff --git a/tests/component/install-app-analytics.test.tsx b/tests/component/install-app-analytics.test.tsx index b982ed91..c686d680 100644 --- a/tests/component/install-app-analytics.test.tsx +++ b/tests/component/install-app-analytics.test.tsx @@ -59,6 +59,7 @@ describe("install app analytics", () => { copy={copy} allToolsLabel="All tools" trustCenterLabel="Trust Center" + localDataControlsLabel="Local data controls" offlineMatrixTitle="Offline support matrix" offlineMatrixDescription="Review which workflows keep running after cache warm-up." offlineMatrixLink="Offline matrix" diff --git a/tests/component/install-app-page.test.tsx b/tests/component/install-app-page.test.tsx index 7cea2cd4..48a79513 100644 --- a/tests/component/install-app-page.test.tsx +++ b/tests/component/install-app-page.test.tsx @@ -48,6 +48,7 @@ describe("install app page", () => { copy={getInstallPageCopy("en")} allToolsLabel="All tools" trustCenterLabel="Trust Center" + localDataControlsLabel="Local data controls" offlineMatrixTitle="Offline support matrix" offlineMatrixDescription="Review which workflows keep running after cache warm-up." offlineMatrixLink="Offline matrix" @@ -58,6 +59,7 @@ describe("install app page", () => { expect(browseLink).toHaveAttribute("href", getAllToolsHref("en")) expect(browseLink).not.toHaveAttribute("href", "/en/format-validate") expect(screen.getByRole("link", { name: "Trust Center" })).toHaveAttribute("href", "/en/trust-center") + expect(screen.getByRole("link", { name: "Local data controls" })).toHaveAttribute("href", "/en/privacy#local-data-controls") expect(screen.getByRole("link", { name: "Extension and desktop research" })).toHaveAttribute("href", "/en/distribution-research") expect(screen.getByRole("heading", { name: "Offline support matrix" })).toBeInTheDocument() expect(screen.getByRole("link", { name: "Offline matrix" })).toHaveAttribute("href", "/en/trust-center#offline-support-matrix") @@ -72,6 +74,7 @@ describe("install app page", () => { copy={copy} allToolsLabel="所有工具" trustCenterLabel="信任中心" + localDataControlsLabel="本地数据控制" offlineMatrixTitle="离线支持矩阵" offlineMatrixDescription="查看缓存预热后哪些流程可继续运行。" offlineMatrixLink="离线矩阵" @@ -107,6 +110,7 @@ describe("install app page", () => { copy={getInstallPageCopy("en")} allToolsLabel="All tools" trustCenterLabel="Trust Center" + localDataControlsLabel="Local data controls" offlineMatrixTitle="Offline support matrix" offlineMatrixDescription="Review which workflows keep running after cache warm-up." offlineMatrixLink="Offline matrix" @@ -137,6 +141,7 @@ describe("install app page", () => { copy={copy} allToolsLabel="All tools" trustCenterLabel="Trust Center" + localDataControlsLabel="Local data controls" offlineMatrixTitle="Offline support matrix" offlineMatrixDescription="Review which workflows keep running after cache warm-up." offlineMatrixLink="Offline matrix" diff --git a/tests/component/privacy-page.test.tsx b/tests/component/privacy-page.test.tsx index 49615c53..ed70ef2e 100644 --- a/tests/component/privacy-page.test.tsx +++ b/tests/component/privacy-page.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from "@testing-library/react" +import { fireEvent, render, screen, waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" import PrivacyPage from "@/app/[lang]/privacy/page" import { LangProvider } from "@/core/i18n/lang-provider" @@ -81,4 +81,71 @@ describe("PrivacyPage", () => { expect(screen.getByText("Locally opted out")).toBeInTheDocument() expect(screen.getByText("Analytics opt-out saved on this browser.")).toBeInTheDocument() }) + + it("shows local data categories and clears favorites and recents independently", async () => { + window.localStorage.setItem("byteflow:tools:favorites", JSON.stringify([{ toolKey: "json-formatter", updatedAt: "2026-01-01T00:00:00.000Z" }])) + window.localStorage.setItem("byteflow:tools:recent", JSON.stringify([{ toolKey: "jwt-decoder", updatedAt: "2026-01-01T00:00:00.000Z" }])) + window.localStorage.setItem("byteflow:preferred-locale", "en") + window.localStorage.setItem("byteflow:analytics:opt-out", "1") + + render( + + + , + ) + + expect(screen.getByRole("link", { name: "Local data controls" })).toHaveAttribute("href", "#local-data-controls") + expect(screen.getByRole("heading", { name: "Local data controls" })).toBeInTheDocument() + expect(screen.getByRole("heading", { name: "Favorites" })).toBeInTheDocument() + expect(screen.getByRole("heading", { name: "Recent tools" })).toBeInTheDocument() + expect(screen.getByRole("heading", { name: "Safe preferences" })).toBeInTheDocument() + await waitFor(() => expect(screen.getAllByText("1 saved").length).toBeGreaterThanOrEqual(3)) + expect(screen.getByText(/Tool input, output, files, logs, tokens/)).toBeInTheDocument() + expect(screen.getByRole("link", { name: "Install App cache controls" })).toHaveAttribute("href", "/en/install-app") + expect(screen.getByRole("link", { name: "DevTools verification steps" })).toHaveAttribute("href", "/en/trust-center#verify-local-processing") + + fireEvent.click(screen.getByRole("button", { name: "Clear favorites" })) + await waitFor(() => expect(window.localStorage.getItem("byteflow:tools:favorites")).toBe("[]")) + expect(JSON.parse(window.localStorage.getItem("byteflow:tools:recent") ?? "[]")).toHaveLength(1) + expect(screen.getByText("Cleared 1 favorite tools.")).toBeInTheDocument() + + fireEvent.click(screen.getByRole("button", { name: "Clear recent tools" })) + await waitFor(() => expect(window.localStorage.getItem("byteflow:tools:recent")).toBe("[]")) + expect(screen.getByText("Cleared 1 recent tools.")).toBeInTheDocument() + + fireEvent.click(screen.getByRole("button", { name: "Clear preferences" })) + await waitFor(() => expect(window.localStorage.getItem("byteflow:preferred-locale")).toBeNull()) + expect(window.localStorage.getItem("byteflow:analytics:opt-out")).toBe("1") + expect(screen.getByText("Cleared 1 preference items.")).toBeInTheDocument() + }) + + it("clears only byteflow PWA cache buckets from local data controls", async () => { + const deleteCache = vi.fn().mockResolvedValue(true) + Object.defineProperty(window, "caches", { + configurable: true, + value: { + keys: vi.fn().mockResolvedValue([ + "byteflow-app-shell-vtest", + "byteflow-runtime-vtest", + "third-party-cache", + ]), + delete: deleteCache, + }, + }) + + render( + + + , + ) + + fireEvent.click(screen.getByRole("button", { name: "Clear cached app files" })) + + await waitFor(() => { + expect(deleteCache).toHaveBeenCalledWith("byteflow-app-shell-vtest") + expect(deleteCache).toHaveBeenCalledWith("byteflow-runtime-vtest") + }) + expect(deleteCache).not.toHaveBeenCalledWith("third-party-cache") + expect(await screen.findByText("Cached app files cleared.")).toBeInTheDocument() + }) }) diff --git a/tests/component/trust-center-page.test.tsx b/tests/component/trust-center-page.test.tsx index dad01b68..eaac6c06 100644 --- a/tests/component/trust-center-page.test.tsx +++ b/tests/component/trust-center-page.test.tsx @@ -25,6 +25,7 @@ describe("TrustCenterPage", () => { expect(screen.getByText(/clear cached app files from the install page/i)).toBeInTheDocument() expect(screen.getByRole("heading", { name: "Offline support matrix" })).toBeInTheDocument() expect(screen.getByRole("link", { name: "Offline matrix" })).toHaveAttribute("href", "#offline-support-matrix") + expect(screen.getByRole("link", { name: "Local data controls" })).toHaveAttribute("href", "/en/privacy#local-data-controls") expect(screen.getByRole("rowheader", { name: "Browser-local tools" })).toBeInTheDocument() expect(screen.getByRole("rowheader", { name: "File-based tools" })).toBeInTheDocument() expect(screen.getByRole("rowheader", { name: "Pipeline Builder" })).toBeInTheDocument()