diff --git a/docs/references/terminology-ja-JP.md b/docs/references/terminology-ja-JP.md index 32b97e151..4747125c8 100644 --- a/docs/references/terminology-ja-JP.md +++ b/docs/references/terminology-ja-JP.md @@ -83,7 +83,7 @@ | directory / path | `ディレクトリ` / `パス` | ファイルシステムを扱う開発機能では、現行の `ディレクトリ` を維持します。一般向け機能へ拡大する場合は別途確認します。 | `open_directory`, `open_backup_dir`, `script_operation_description`, `watch_file_description` | | panel / console | `パネル` / `コンソール` | `panel` は `パネル`、開発者ツールの console は `コンソール` とし、相互に置換しません。 | `background_script_description`, `build_success_message` | | check / confirm | `チェック` / `確認` | 更新確認の UI には両表現が現存します。新しい文言では周辺 UI に合わせ、統一はレビュー後に行います。 | `check_update`, `updatepage.main_header`, `updatepage.status_checking_updates` | -| source | `ソース` / `インストール元` | 外部リソースや出所一般は `ソース`、導入元を明示する欄では `インストール元` を使用しています。 | `source`, `importpage.col_source`, `from_legitimate_sources_warning` | +| source | `ソース` / `インストール元` | 外部リソースや出所一般は `ソース`、導入元を明示する欄では `インストール元` を使用しています。 | `source`, `importpage.col_source` | | query / filter | `検索` / `絞り込み` / `クエリ` | ログ検索 UI の操作名は `検索`、結果を条件で狭める説明は `絞り込み` を優先します。API や問い合わせ言語など技術文脈では `クエリ` を使用できます。 | `query`, `enter_filter_conditions`, `filtered_logs` | | match / exclude | `対象` / `除外`、ラベルでは `対象サイト(@match)` / `除外サイト(@exclude)` | ユーザー向け操作では現行の `対象` / `除外` を優先します。`@match` / `@exclude` は識別子として保持します。現行の `マッチ` 文言は後日レビュー対象です。 | `add_match`, `add_exclude`, `website_match`, `website_exclude`, `match` | | storage access / operation | `ストレージにアクセス` / `ストレージ` | 許可ダイアログは一般のファイルストレージへのアクセス確認であり、同期機能に限定しません。タイトルでは対象への `アクセス` を明確にし、説明文で具体的操作を補います。 | `script_operation_title`, `script_storage`, `storage_api` | diff --git a/src/locales/de-DE/install.json b/src/locales/de-DE/install.json index 485602949..f22259290 100644 --- a/src/locales/de-DE/install.json +++ b/src/locales/de-DE/install.json @@ -13,6 +13,16 @@ "perm_card_title": "Dieses Skript erhält die folgenden Berechtigungen", "perm_card_hint": "Bitte vor der Installation bestätigen", "perm_card_empty": "Dieses Skript fordert keine besonderen Berechtigungen an", + "perm_card_title_changed": "Berechtigungsänderungen", + "perm_card_compare": "Verglichen mit installierter Version {{version}}", + "perm_no_change": "Keine Änderung", + "perm_change_added": "Hinzugefügt", + "perm_change_removed": "Entfernt", + "perm_change_unchanged": "Unverändert", + "perm_unchanged_more": "{{count}} unverändert", + "perm_card_title_short": "Berechtigungen", + "perm_card_same_as": "Identisch mit {{version}}", + "perm_card_summary": "{{kinds}} Kategorien · {{count}} Einträge", "perm_match_label": "Läuft auf", "perm_match_summary": "Das Skript läuft auf diesen Websites und verändert die Seiten", "perm_connect_label": "Cross-Origin-Zugriff", @@ -48,11 +58,6 @@ "watching_title": "Dateiänderungen werden überwacht", "watching_file_desc": "Beim Speichern von {{file}} wird das Skript automatisch neu installiert und synchronisiert", "watching_last_sync": "Zuletzt synchronisiert {{time}}", - "warning_title": "Bitte bestätige, dass das Skript aus einer vertrauenswürdigen Quelle stammt", - "warning_risk_connect": "es kann auf alle Domains zugreifen", - "warning_risk_antifeature": "es deklariert Anti-Features", - "warning_risk_join": " und ", - "warning_risk_tail": " — mit Vorsicht installieren.", "action_note_install": "Mit der Installation vertraust du der Quelle und dem Autor dieses Skripts", "action_note_update": "Mit der Aktualisierung akzeptierst du diese Code- und Berechtigungsänderung", "action_note_subscribe": "Mit der Installation vertraust du diesem Abonnement und seinem Autor", @@ -230,7 +235,6 @@ "invalid_page": "Ungültige Seite", "frame_blocked_title": "Einbettung blockiert", "frame_blocked_desc": "Aus Sicherheitsgründen kann die Installationsseite nicht in einem iframe eingebettet werden. Bitte öffnen Sie sie direkt in einem Browser-Tab.", - "from_legitimate_sources_warning": "Bitte installieren Sie Skripte aus legitimen Quellen! Unbekannte Skripte können Ihre Privatsphäre verletzen oder bösartige Operationen durchführen!", "referral_link_title": "Empfehlungslink", "ads_title": "Mit Werbung", "payment_title": "Bezahltes Skript", diff --git a/src/locales/en-US/install.json b/src/locales/en-US/install.json index a2619096a..64c24de4f 100644 --- a/src/locales/en-US/install.json +++ b/src/locales/en-US/install.json @@ -13,6 +13,16 @@ "perm_card_title": "This script will be granted the following permissions", "perm_card_hint": "Please confirm before installing", "perm_card_empty": "This script does not request any special permissions", + "perm_card_title_changed": "Permission changes", + "perm_card_compare": "Compared with installed {{version}}", + "perm_no_change": "No change", + "perm_change_added": "Added", + "perm_change_removed": "Removed", + "perm_change_unchanged": "Unchanged", + "perm_unchanged_more": "{{count}} unchanged", + "perm_card_title_short": "Permissions", + "perm_card_same_as": "Same as {{version}}", + "perm_card_summary": "{{kinds}} categories · {{count}} items", "perm_match_label": "Runs on", "perm_match_summary": "The script runs on these websites and modifies the pages", "perm_connect_label": "Cross-origin access", @@ -48,11 +58,6 @@ "watching_title": "Watching file changes", "watching_file_desc": "Saving {{file}} will reinstall and sync the script automatically", "watching_last_sync": "Last synced {{time}}", - "warning_title": "Please confirm the script comes from a trusted source", - "warning_risk_connect": "it can access all domains", - "warning_risk_antifeature": "it declares anti-features", - "warning_risk_join": " and ", - "warning_risk_tail": " — install with caution.", "action_note_install": "Installing means you trust this script's source and author", "action_note_update": "Updating means you accept this code and permission change", "action_note_subscribe": "Installing means you trust this subscription and its author", @@ -230,7 +235,6 @@ "invalid_page": "Invalid Page", "frame_blocked_title": "Embedding Blocked", "frame_blocked_desc": "For security reasons, the install page cannot be embedded in an iframe. Please open it directly in a browser tab.", - "from_legitimate_sources_warning": "Please install scripts from legitimate sources! Unknown scripts may invade your privacy or conduct malicious operations.", "referral_link_title": "Referral Link", "ads_title": "Ads", "payment_title": "Payment", diff --git a/src/locales/ja-JP/install.json b/src/locales/ja-JP/install.json index 89547e531..0abd1017c 100644 --- a/src/locales/ja-JP/install.json +++ b/src/locales/ja-JP/install.json @@ -13,6 +13,16 @@ "perm_card_title": "このスクリプトには以下の権限が付与されます", "perm_card_hint": "インストール前に確認してください", "perm_card_empty": "このスクリプトは特別な権限を要求しません", + "perm_card_title_changed": "権限の変更", + "perm_card_compare": "インストール済み {{version}} との比較", + "perm_no_change": "変更なし", + "perm_change_added": "追加", + "perm_change_removed": "削除", + "perm_change_unchanged": "変更なし", + "perm_unchanged_more": "変更なし {{count}} 件", + "perm_card_title_short": "権限", + "perm_card_same_as": "{{version}} と同じ", + "perm_card_summary": "{{kinds}} カテゴリ · {{count}} 件", "perm_match_label": "実行サイト", "perm_match_summary": "スクリプトはこれらのサイトで動作し、ページを変更します", "perm_connect_label": "クロスオリジンアクセス", @@ -48,11 +58,6 @@ "watching_title": "ファイルの変更を監視中", "watching_file_desc": "{{file}} を保存すると、自動的に再インストールしてスクリプトを同期します", "watching_last_sync": "最終同期 {{time}}", - "warning_title": "信頼できるソースのスクリプトか確認してください", - "warning_risk_connect": "すべてのドメインにアクセスできます", - "warning_risk_antifeature": "アンチフィーチャーが宣言されています", - "warning_risk_join": "、", - "warning_risk_tail": " — 慎重にインストールしてください。", "action_note_install": "インストールは、このスクリプトのソースと作者を信頼することを意味します", "action_note_update": "更新は、このコードと権限の変更を受け入れることを意味します", "action_note_subscribe": "インストールは、このサブスクリプションとその作者を信頼することを意味します", @@ -230,7 +235,6 @@ "invalid_page": "無効なページ", "frame_blocked_title": "埋め込みがブロックされました", "frame_blocked_desc": "セキュリティ上の理由により、インストールページを iframe に埋め込むことはできません。ブラウザのタブで直接開いてください。", - "from_legitimate_sources_warning": "正当なソースからスクリプトをインストールしてください!不明なスクリプトはプライバシーを侵害したり、悪意のある操作を行う可能性があります!", "referral_link_title": "紹介リンク", "ads_title": "広告付き", "payment_title": "有料スクリプト", diff --git a/src/locales/ko-KR/install.json b/src/locales/ko-KR/install.json index f11d0a884..a06879c34 100644 --- a/src/locales/ko-KR/install.json +++ b/src/locales/ko-KR/install.json @@ -13,6 +13,16 @@ "perm_card_title": "이 스크립트에 다음 권한이 부여됩니다", "perm_card_hint": "설치하기 전에 확인해 주세요", "perm_card_empty": "이 스크립트는 특별한 권한을 요청하지 않습니다", + "perm_card_title_changed": "권한 변경", + "perm_card_compare": "설치된 {{version}}과 비교", + "perm_no_change": "변경 없음", + "perm_change_added": "추가됨", + "perm_change_removed": "제거됨", + "perm_change_unchanged": "변경 없음", + "perm_unchanged_more": "변경 없음 {{count}}개", + "perm_card_title_short": "권한", + "perm_card_same_as": "{{version}}과(와) 동일", + "perm_card_summary": "{{kinds}}개 분류 · {{count}}개 항목", "perm_match_label": "실행 사이트", "perm_match_summary": "스크립트가 다음 사이트에서 실행되며 페이지를 수정합니다", "perm_connect_label": "교차 출처 접근", @@ -48,11 +58,6 @@ "watching_title": "파일 변경 감시 중", "watching_file_desc": "{{file}} 파일을 저장하면 스크립트가 자동으로 재설치되고 동기화됩니다", "watching_last_sync": "마지막 동기화 {{time}}", - "warning_title": "신뢰할 수 있는 출처의 스크립트인지 확인하세요", - "warning_risk_connect": "모든 도메인에 접근할 수 있음", - "warning_risk_antifeature": "안티피처(anti-feature)가 포함되어 있음", - "warning_risk_join": ", ", - "warning_risk_tail": " — 설치 시 주의하세요.", "action_note_install": "설치하면 이 스크립트의 출처와 제작자를 신뢰하는 것입니다", "action_note_update": "업데이트하면 이 코드와 권한 변경 사항을 승인하는 것입니다", "action_note_subscribe": "설치하면 이 구독과 그 제작자를 신뢰하는 것입니다", @@ -230,7 +235,6 @@ "invalid_page": "잘못된 페이지", "frame_blocked_title": "삽입이 차단됨", "frame_blocked_desc": "보안상의 이유로 설치 페이지는 iframe에 삽입할 수 없습니다. 브라우저 탭에서 직접 열어주세요.", - "from_legitimate_sources_warning": "반드시 정당한 출처에서 스크립트를 설치하세요! 출처가 불분명한 스크립트는 개인정보를 침해하거나 악의적인 동작을 수행할 수 있습니다.", "referral_link_title": "추천 링크", "ads_title": "광고", "payment_title": "유료", diff --git a/src/locales/pt-BR/install.json b/src/locales/pt-BR/install.json index cb38a1aee..93047d702 100644 --- a/src/locales/pt-BR/install.json +++ b/src/locales/pt-BR/install.json @@ -13,6 +13,16 @@ "perm_card_title": "A este script serão concedidas as seguintes permissões", "perm_card_hint": "Por favor, confirme antes de instalar", "perm_card_empty": "Este script não solicita nenhuma permissão especial", + "perm_card_title_changed": "Alterações de permissões", + "perm_card_compare": "Comparado com a versão instalada {{version}}", + "perm_no_change": "Sem alterações", + "perm_change_added": "Adicionado", + "perm_change_removed": "Removido", + "perm_change_unchanged": "Inalterado", + "perm_unchanged_more": "{{count}} inalterado(s)", + "perm_card_title_short": "Permissões", + "perm_card_same_as": "Iguais à {{version}}", + "perm_card_summary": "{{kinds}} categorias · {{count}} itens", "perm_match_label": "Executa em", "perm_match_summary": "O script é executado nestes sites e modifica as páginas", "perm_connect_label": "Acesso entre origens (CORS)", @@ -48,11 +58,6 @@ "watching_title": "Monitorando alterações do arquivo", "watching_file_desc": "Salvar {{file}} reinstalará e sincronizará o script automaticamente", "watching_last_sync": "Última sincronização {{time}}", - "warning_title": "Por favor, confirme que o script vem de uma origem confiável", - "warning_risk_connect": "ele pode acessar todos os domínios", - "warning_risk_antifeature": "ele declara metadados @antifeature", - "warning_risk_join": " e ", - "warning_risk_tail": " — instale com cautela.", "action_note_install": "Instalar significa que você confia na origem e no autor deste script", "action_note_update": "Atualizar significa que você aceita esta alteração de código e permissão", "action_note_subscribe": "Instalar significa que você confia nesta assinatura e em seu autor", @@ -230,7 +235,6 @@ "invalid_page": "Página inválida", "frame_blocked_title": "Incorporação bloqueada", "frame_blocked_desc": "Por motivos de segurança, a página de instalação não pode ser incorporada em um iframe. Abra-a diretamente em uma aba do navegador.", - "from_legitimate_sources_warning": "Por favor, instale scripts de fontes legítimas! Scripts desconhecidos podem invadir sua privacidade ou realizar operações maliciosas.", "referral_link_title": "Link de indicação", "ads_title": "Anúncios", "payment_title": "Pagamento", diff --git a/src/locales/ru-RU/install.json b/src/locales/ru-RU/install.json index eb759a6f5..f452106b2 100644 --- a/src/locales/ru-RU/install.json +++ b/src/locales/ru-RU/install.json @@ -13,6 +13,16 @@ "perm_card_title": "Этому скрипту будут предоставлены следующие разрешения", "perm_card_hint": "Подтвердите перед установкой", "perm_card_empty": "Этот скрипт не запрашивает особых разрешений", + "perm_card_title_changed": "Изменения разрешений", + "perm_card_compare": "Сравнение с установленной {{version}}", + "perm_no_change": "Без изменений", + "perm_change_added": "Добавлено", + "perm_change_removed": "Удалено", + "perm_change_unchanged": "Без изменений", + "perm_unchanged_more": "Без изменений: {{count}}", + "perm_card_title_short": "Разрешения", + "perm_card_same_as": "Совпадают с {{version}}", + "perm_card_summary": "Категорий: {{kinds}} · элементов: {{count}}", "perm_match_label": "Запускается на", "perm_match_summary": "Скрипт работает на этих сайтах и изменяет страницы", "perm_connect_label": "Кросс-доменный доступ", @@ -48,11 +58,6 @@ "watching_title": "Отслеживание изменений файла", "watching_file_desc": "Сохранение {{file}} автоматически переустановит и синхронизирует скрипт", "watching_last_sync": "Последняя синхронизация {{time}}", - "warning_title": "Убедитесь, что скрипт получен из доверенного источника", - "warning_risk_connect": "он может получить доступ ко всем доменам", - "warning_risk_antifeature": "он объявляет антифункции", - "warning_risk_join": " и ", - "warning_risk_tail": " — устанавливайте с осторожностью.", "action_note_install": "Установка означает, что вы доверяете источнику и автору этого скрипта", "action_note_update": "Обновление означает, что вы принимаете изменения кода и разрешений", "action_note_subscribe": "Установка означает, что вы доверяете этой подписке и её автору", @@ -230,7 +235,6 @@ "invalid_page": "Недействительная страница", "frame_blocked_title": "Встраивание заблокировано", "frame_blocked_desc": "В целях безопасности страница установки не может быть встроена в iframe. Пожалуйста, откройте её напрямую во вкладке браузера.", - "from_legitimate_sources_warning": "Пожалуйста, устанавливайте скрипты только из законных источников! Неизвестные скрипты могут нарушить вашу конфиденциальность или выполнить злонамеренные операции!", "referral_link_title": "Реферальная ссылка", "ads_title": "Содержит рекламу", "payment_title": "Платный скрипт", diff --git a/src/locales/tr-TR/install.json b/src/locales/tr-TR/install.json index 9792ff3b9..0cb0a6752 100644 --- a/src/locales/tr-TR/install.json +++ b/src/locales/tr-TR/install.json @@ -13,6 +13,16 @@ "perm_card_title": "Bu betiğe aşağıdaki izinler verilecek", "perm_card_hint": "Lütfen yüklemeden önce onaylayın", "perm_card_empty": "Bu betik herhangi bir özel izin istemiyor", + "perm_card_title_changed": "İzin değişiklikleri", + "perm_card_compare": "Yüklü {{version}} ile karşılaştırma", + "perm_no_change": "Değişiklik yok", + "perm_change_added": "Eklendi", + "perm_change_removed": "Kaldırıldı", + "perm_change_unchanged": "Değişmedi", + "perm_unchanged_more": "{{count}} değişmedi", + "perm_card_title_short": "İzinler", + "perm_card_same_as": "{{version}} ile aynı", + "perm_card_summary": "{{kinds}} kategori · {{count}} öğe", "perm_match_label": "Çalıştırıldığı yer", "perm_match_summary": "Betik bu web sitelerinde çalışır ve sayfaları değiştirir", "perm_connect_label": "Kaynaklar arası erişim", @@ -48,11 +58,6 @@ "watching_title": "Dosya değişiklikleri izleniyor", "watching_file_desc": "{{file}} kaydedildiğinde betik otomatik olarak yeniden yüklenir ve eşitlenir", "watching_last_sync": "Son eşitleme: {{time}}", - "warning_title": "Lütfen betiğin güvenilir bir kaynaktan geldiğini onaylayın", - "warning_risk_connect": "tüm etki alanlarına erişebilir", - "warning_risk_antifeature": "istenmeyen riskli özellikler bildiriyor", - "warning_risk_join": " ve ", - "warning_risk_tail": " — dikkatli yükleyin.", "action_note_install": "Yüklemek, bu betiğin kaynağına ve yazarına güvendiğiniz anlamına gelir", "action_note_update": "Güncellemek, bu kodu ve izin değişikliğini kabul ettiğiniz anlamına gelir", "action_note_subscribe": "Yüklemek, bu aboneliğe ve yazarına güvendiğiniz anlamına gelir", @@ -230,7 +235,6 @@ "invalid_page": "Geçersiz Sayfa", "frame_blocked_title": "Gömme Engellendi", "frame_blocked_desc": "Güvenlik nedeniyle yükleme sayfası bir iframe içine gömülemez. Lütfen doğrudan bir tarayıcı sekmesinde açın.", - "from_legitimate_sources_warning": "Lütfen betikleri meşru kaynaklardan yükleyin! Bilinmeyen betikler gizliliğinizi ihlal edebilir veya kötü amaçlı işlemler gerçekleştirebilir.", "referral_link_title": "Yönlendirme Bağlantısı", "ads_title": "Reklamlar", "payment_title": "Ödeme", diff --git a/src/locales/vi-VN/install.json b/src/locales/vi-VN/install.json index ed556c9db..6f99c54d7 100644 --- a/src/locales/vi-VN/install.json +++ b/src/locales/vi-VN/install.json @@ -13,6 +13,16 @@ "perm_card_title": "Script này sẽ được cấp các quyền sau", "perm_card_hint": "Vui lòng xác nhận trước khi cài đặt", "perm_card_empty": "Script này không yêu cầu bất kỳ quyền đặc biệt nào", + "perm_card_title_changed": "Thay đổi quyền", + "perm_card_compare": "So với {{version}} đã cài đặt", + "perm_no_change": "Không thay đổi", + "perm_change_added": "Đã thêm", + "perm_change_removed": "Đã xóa", + "perm_change_unchanged": "Không đổi", + "perm_unchanged_more": "{{count}} không đổi", + "perm_card_title_short": "Quyền", + "perm_card_same_as": "Giống {{version}}", + "perm_card_summary": "{{kinds}} nhóm · {{count}} mục", "perm_match_label": "Chạy trên", "perm_match_summary": "Script chạy trên các website này và thay đổi trang", "perm_connect_label": "Truy cập khác nguồn gốc", @@ -48,11 +58,6 @@ "watching_title": "Đang theo dõi thay đổi tệp", "watching_file_desc": "Lưu {{file}} sẽ tự động cài đặt lại và đồng bộ script", "watching_last_sync": "Đồng bộ lần cuối {{time}}", - "warning_title": "Vui lòng xác nhận script đến từ nguồn đáng tin cậy", - "warning_risk_connect": "nó có thể truy cập mọi tên miền", - "warning_risk_antifeature": "nó khai báo các tính năng đối nghịch", - "warning_risk_join": " và ", - "warning_risk_tail": " — hãy cài đặt thận trọng.", "action_note_install": "Cài đặt nghĩa là bạn tin tưởng nguồn và tác giả của script này", "action_note_update": "Cập nhật nghĩa là bạn chấp nhận thay đổi mã và quyền này", "action_note_subscribe": "Cài đặt nghĩa là bạn tin tưởng đăng ký này và tác giả của nó", @@ -230,7 +235,6 @@ "invalid_page": "Trang không hợp lệ", "frame_blocked_title": "Nhúng trang bị chặn", "frame_blocked_desc": "Vì lý do bảo mật, trang cài đặt không thể được nhúng trong iframe. Vui lòng mở trực tiếp trong một tab trình duyệt.", - "from_legitimate_sources_warning": "Vui lòng cài đặt script từ các nguồn hợp pháp! Script không rõ nguồn gốc có thể xâm phạm quyền riêng tư của bạn hoặc thực hiện các hoạt động độc hại.", "referral_link_title": "Liên kết giới thiệu", "ads_title": "Quảng cáo", "payment_title": "Thanh toán", diff --git a/src/locales/zh-CN/install.json b/src/locales/zh-CN/install.json index 6e1eba734..3947d3f7a 100644 --- a/src/locales/zh-CN/install.json +++ b/src/locales/zh-CN/install.json @@ -13,6 +13,16 @@ "perm_card_title": "此脚本将获得以下权限", "perm_card_hint": "安装前请确认", "perm_card_empty": "此脚本不请求任何特殊权限", + "perm_card_title_changed": "权限变化", + "perm_card_compare": "对比已安装的 {{version}}", + "perm_no_change": "无变化", + "perm_change_added": "新增", + "perm_change_removed": "已移除", + "perm_change_unchanged": "未变动", + "perm_unchanged_more": "未变动 {{count}} 项", + "perm_card_title_short": "权限", + "perm_card_same_as": "与 {{version}} 相同", + "perm_card_summary": "{{kinds}} 类 {{count}} 项", "perm_match_label": "运行网站", "perm_match_summary": "脚本会在这些网站上运行并修改页面", "perm_connect_label": "跨域访问", @@ -48,11 +58,6 @@ "watching_title": "正在监听文件变化", "watching_file_desc": "保存 {{file}} 会自动重新安装并同步脚本", "watching_last_sync": "最后更新 {{time}}", - "warning_title": "请确认脚本来自可信来源", - "warning_risk_connect": "它可以访问所有域名", - "warning_risk_antifeature": "它声明了反功能特性", - "warning_risk_join": " 且 ", - "warning_risk_tail": " — 请谨慎安装。", "action_note_install": "安装代表您信任该脚本的来源与作者", "action_note_update": "更新代表您接受此代码与权限变更", "action_note_subscribe": "安装代表您信任该订阅及其作者", @@ -230,7 +235,6 @@ "invalid_page": "无效页面", "frame_blocked_title": "禁止内嵌访问", "frame_blocked_desc": "出于安全考虑,安装页不允许在 iframe 中嵌入显示,请在浏览器标签页中直接打开。", - "from_legitimate_sources_warning": "请从合法来源安装脚本!未知的脚本可能会侵犯您的隐私或者做出恶意的操作!", "referral_link_title": "推荐链接", "ads_title": "附带广告", "payment_title": "付费脚本", diff --git a/src/locales/zh-TW/install.json b/src/locales/zh-TW/install.json index 4e16a8c38..6998fe976 100644 --- a/src/locales/zh-TW/install.json +++ b/src/locales/zh-TW/install.json @@ -13,6 +13,16 @@ "perm_card_title": "此腳本將取得以下權限", "perm_card_hint": "安裝前請確認", "perm_card_empty": "此腳本不要求任何特殊權限", + "perm_card_title_changed": "權限變更", + "perm_card_compare": "對比已安裝的 {{version}}", + "perm_no_change": "無變更", + "perm_change_added": "新增", + "perm_change_removed": "已移除", + "perm_change_unchanged": "未變更", + "perm_unchanged_more": "未變更 {{count}} 項", + "perm_card_title_short": "權限", + "perm_card_same_as": "與 {{version}} 相同", + "perm_card_summary": "{{kinds}} 類 {{count}} 項", "perm_match_label": "執行網站", "perm_match_summary": "腳本會在這些網站上執行並修改頁面", "perm_connect_label": "跨域存取", @@ -48,11 +58,6 @@ "watching_title": "正在監聽檔案變化", "watching_file_desc": "儲存 {{file}} 會自動重新安裝並同步腳本", "watching_last_sync": "最後更新 {{time}}", - "warning_title": "請確認腳本來自可信來源", - "warning_risk_connect": "它可以存取所有網域", - "warning_risk_antifeature": "它宣告了反功能特性", - "warning_risk_join": " 且 ", - "warning_risk_tail": " — 請謹慎安裝。", "action_note_install": "安裝代表您信任該腳本的來源與作者", "action_note_update": "更新代表您接受此程式碼與權限變更", "action_note_subscribe": "安裝代表您信任該訂閱及其作者", @@ -230,7 +235,6 @@ "invalid_page": "無效頁面", "frame_blocked_title": "禁止內嵌存取", "frame_blocked_desc": "基於安全考量,安裝頁不允許在 iframe 中嵌入顯示,請在瀏覽器分頁中直接開啟。", - "from_legitimate_sources_warning": "請從合法來源安裝腳本!未知的腳本可能會侵犯您的隱私或進行惡意操作!", "referral_link_title": "推薦連結", "ads_title": "附帶廣告", "payment_title": "付費腳本", diff --git a/src/pages/install/App.test.tsx b/src/pages/install/App.test.tsx index 260b34f49..48b874fd4 100644 --- a/src/pages/install/App.test.tsx +++ b/src/pages/install/App.test.tsx @@ -323,3 +323,66 @@ describe("Install App 安装反馈", () => { expect(screen.getByTestId("install-error-bar-message")).toHaveTextContent("技能写入失败"); }); }); + +describe("Install App 权限卡的更新基线", () => { + it("更新场景把已安装版本作为权限对比基线传给权限卡", () => { + mockHook.mockReturnValue({ + ...baseHook(), + state: { + status: "ready", + view: readyView({ + isUpdate: true, + version: { kind: "update", oldVersion: "1.2.3", newVersion: "2.3.1", changed: true }, + permissions: [ + { + kind: "connect", + risk: "warn", + values: ["api.a.com"], + sensitive: [], + diff: { added: ["api.a.com"], removed: [] }, + }, + ], + }), + }, + }); + render(); + expect(screen.getByText("对比已安装的 1.2.3")).toBeInTheDocument(); + expect(screen.getByText("权限变化")).toBeInTheDocument(); + }); + + it("全新安装不传基线,权限卡维持安装态卡头", () => { + mockHook.mockReturnValue({ ...baseHook(), state: { status: "ready", view: readyView() } }); + render(); + expect(screen.getByText("此脚本将获得以下权限")).toBeInTheDocument(); + expect(screen.queryByText("权限变化")).not.toBeInTheDocument(); + }); +}); + +describe("Install App 不再渲染安全警示条", () => { + it("全新安装页没有安全警示条", () => { + mockHook.mockReturnValue({ ...baseHook(), state: { status: "ready", view: readyView() } }); + render(); + expect(screen.queryByTestId("install-warning-title")).not.toBeInTheDocument(); + expect(screen.queryByTestId("install-warning-desc")).not.toBeInTheDocument(); + }); + + it("声明了危险权限的更新页同样没有安全警示条", () => { + mockHook.mockReturnValue({ + ...baseHook(), + state: { + status: "ready", + view: readyView({ + isUpdate: true, + version: { kind: "update", oldVersion: "1.2.3", newVersion: "2.3.1", changed: true }, + antifeatures: ["ads"], + permissions: [ + { kind: "connect", risk: "danger", values: ["*"], sensitive: [], diff: { added: [], removed: [] } }, + ], + }), + }, + }); + render(); + expect(screen.queryByTestId("install-warning-title")).not.toBeInTheDocument(); + expect(screen.queryByTestId("install-warning-risk")).not.toBeInTheDocument(); + }); +}); diff --git a/src/pages/install/App.tsx b/src/pages/install/App.tsx index a0857f7d6..b3629ba45 100644 --- a/src/pages/install/App.tsx +++ b/src/pages/install/App.tsx @@ -10,7 +10,6 @@ import { SubscribeScripts } from "./components/SubscribeScripts"; import { SkillInstallView } from "./components/SkillInstallView"; import { CodePreview } from "./components/CodePreview"; import { InstallActions } from "./components/InstallActions"; -import { InstallWarning } from "./components/InstallWarning"; import { InstallLoading, InstallError, InstallExpired } from "./components/InstallStates"; import { WatchingBanner } from "./components/WatchingBanner"; import { ExternalAccessBanner } from "./components/ExternalAccessBanner"; @@ -235,12 +234,11 @@ export default function App() { {view.isSubscribe ? ( ) : ( - + )} - p.risk === "danger")} - hasAntifeature={view.antifeatures.length > 0} - /> initTestLanguage("zh-CN")); - -afterEach(cleanup); - -describe("InstallWarning 安全警示条", () => { - it("渲染警示标题与说明两段(对照设计稿)", () => { - render(); - expect(screen.getByTestId("install-warning-title")).toBeInTheDocument(); - expect(screen.getByTestId("install-warning-desc")).toBeInTheDocument(); - }); - - it("存在危险权限时附加风险提示分句", () => { - render(); - expect(screen.getByTestId("install-warning-risk")).toBeInTheDocument(); - }); - - it("无风险信号时不渲染风险提示分句", () => { - render(); - expect(screen.queryByTestId("install-warning-risk")).not.toBeInTheDocument(); - }); -}); diff --git a/src/pages/install/components/InstallWarning.tsx b/src/pages/install/components/InstallWarning.tsx deleted file mode 100644 index 11206e0d1..000000000 --- a/src/pages/install/components/InstallWarning.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { ShieldAlert } from "lucide-react"; - -export interface InstallWarningProps { - /** 是否声明了危险权限(如 @connect *) */ - hasDangerPermission: boolean; - /** 是否声明了已知反特性(推广链接/广告/挖矿等) */ - hasAntifeature: boolean; -} - -/** - * 安全警示条(对照设计稿 Alert Warning):标题「请确认脚本来自可信来源」+ 说明, - * 当声明了危险权限或反特性时,附加一句风险提示,引导用户谨慎安装。 - */ -export function InstallWarning({ hasDangerPermission, hasAntifeature }: InstallWarningProps) { - const { t } = useTranslation(["install", "common"]); - const showRisk = hasDangerPermission || hasAntifeature; - - return ( -
- -
- - {t("install:warning_title")} - - - {t("install:from_legitimate_sources_warning")} - {showRisk && ( - - {" "} - {hasDangerPermission && t("install:warning_risk_connect")} - {hasDangerPermission && hasAntifeature && t("install:warning_risk_join")} - {hasAntifeature && t("install:warning_risk_antifeature")} - {t("install:warning_risk_tail")} - - )} - -
-
- ); -} diff --git a/src/pages/install/components/PermissionCard.test.tsx b/src/pages/install/components/PermissionCard.test.tsx index 753d88fa0..605c48c61 100644 --- a/src/pages/install/components/PermissionCard.test.tsx +++ b/src/pages/install/components/PermissionCard.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, beforeAll, afterEach, vi } from "vitest"; -import { render, screen, cleanup } from "@testing-library/react"; +import { render, screen, cleanup, within, fireEvent } from "@testing-library/react"; import { initTestLanguage } from "@Tests/initTestLanguage"; let mobile = false; @@ -47,3 +47,154 @@ describe("PermissionCard 权限卡", () => { expect(screen.queryByText("https://a.com/*")).not.toBeInTheDocument(); }); }); + +describe("PermissionCard 更新差异态", () => { + const changedConnect: PermissionRow = { + kind: "connect", + risk: "danger", + values: ["*", "api.a.com"], + sensitive: [], + diff: { added: ["*"], removed: ["old.a.com"] }, + }; + const sameMatch: PermissionRow = { + kind: "match", + risk: "normal", + values: ["https://a.com/*"], + sensitive: [], + diff: { added: [], removed: [] }, + }; + + it("有变动时卡头显示变化标题、全卡增删计数与对比版本", () => { + render(); + expect(screen.getByText("权限变化")).toBeInTheDocument(); + expect(screen.getByTestId("permission-card-delta")).toHaveTextContent("+1"); + expect(screen.getByTestId("permission-card-delta")).toHaveTextContent("−1"); + expect(screen.getByText("对比已安装的 1.2.3")).toBeInTheDocument(); + }); + + it("有变动的类别展开,无变动的类别塌成单行", () => { + render(); + expect(screen.getAllByTestId("permission-row")).toHaveLength(1); + const collapsed = screen.getByTestId("permission-row-collapsed"); + expect(within(collapsed).getByText("运行网站")).toBeInTheDocument(); + expect(within(collapsed).getByText("无变化")).toBeInTheDocument(); + expect(screen.queryByText("https://a.com/*")).not.toBeInTheDocument(); + }); + + it("点开塌行后该类别取值可见", () => { + render(); + fireEvent.click(screen.getByTestId("permission-row-collapsed")); + expect(screen.getByText("https://a.com/*")).toBeInTheDocument(); + expect(screen.getAllByTestId("permission-row")).toHaveLength(2); + }); + + it("塌行把自己声明为收起的展开控件", () => { + render(); + expect(screen.getByTestId("permission-row-collapsed")).toHaveAttribute("aria-expanded", "false"); + }); + + it("没有任何权限的更新沿用空态卡头与文案,不进入差异呈现", () => { + render(); + expect(screen.getByText("此脚本不请求任何特殊权限")).toBeInTheDocument(); + expect(screen.getByText("安装前请确认")).toBeInTheDocument(); + expect(screen.queryByText("无变化")).not.toBeInTheDocument(); + expect(screen.queryByText("对比已安装的 1.2.3")).not.toBeInTheDocument(); + }); + + it("更新但一项未变时不显示变化标题与增删计数", () => { + render(); + expect(screen.queryByText("权限变化")).not.toBeInTheDocument(); + expect(screen.queryByTestId("permission-card-delta")).not.toBeInTheDocument(); + }); + + it("移动端默认展开有变动的类别,未变动的收起", () => { + mobile = true; + render(); + expect(screen.getByText("*")).toBeInTheDocument(); + expect(screen.queryByText("https://a.com/*")).not.toBeInTheDocument(); + }); + + it("全新安装(无 diff)卡头维持原样", () => { + render(); + expect(screen.getByText("此脚本将获得以下权限")).toBeInTheDocument(); + expect(screen.queryByTestId("permission-card-delta")).not.toBeInTheDocument(); + expect(screen.queryByTestId("permission-row-collapsed")).not.toBeInTheDocument(); + }); +}); + +describe("PermissionCard 更新零变化态", () => { + const same = (kind: PermissionRow["kind"], values: string[]): PermissionRow => ({ + kind, + risk: "normal", + values, + sensitive: [], + diff: { added: [], removed: [] }, + }); + const rows = [same("connect", ["api.a.com"]), same("match", ["https://a.com/*", "https://b.com/*"])]; + + it("零变化时整卡折叠为单行,四类取值都不在 DOM", () => { + render(); + expect(screen.getByTestId("permission-card-collapsed")).toBeInTheDocument(); + expect(screen.queryByText("api.a.com")).not.toBeInTheDocument(); + expect(screen.queryByText("https://a.com/*")).not.toBeInTheDocument(); + expect(screen.queryAllByTestId("permission-row")).toHaveLength(0); + }); + + it("折叠单行给出基线版本与类别数、取值总数", () => { + render(); + const line = screen.getByTestId("permission-card-collapsed"); + expect(within(line).getByText("与 1.2.3 相同")).toBeInTheDocument(); + expect(within(line).getByText("2 类 3 项")).toBeInTheDocument(); + expect(within(line).getByText("无变化")).toBeInTheDocument(); + }); + + it("点开后四类全量呈现,每类标无变化", () => { + render(); + fireEvent.click(screen.getByTestId("permission-card-collapsed")); + expect(screen.getAllByTestId("permission-row")).toHaveLength(2); + expect(screen.getByText("api.a.com")).toBeInTheDocument(); + expect(screen.getByText("https://a.com/*")).toBeInTheDocument(); + expect(screen.getAllByText("无变化")).toHaveLength(3); + }); + + it("折叠单行对辅助技术同样报出无变化与摘要,并声明自己是收起的展开控件", () => { + render(); + const line = screen.getByTestId("permission-card-collapsed"); + expect(line).toHaveAccessibleName(expect.stringContaining("无变化")); + expect(line).toHaveAccessibleName(expect.stringContaining("2 类 3 项")); + expect(line).toHaveAttribute("aria-expanded", "false"); + }); + + it("移动端点开零变化整卡后按高风险默认展开,与全新安装一致", () => { + mobile = true; + const sameDangerConnect: PermissionRow = { ...same("connect", ["*"]), risk: "danger" }; + render(); + fireEvent.click(screen.getByTestId("permission-card-collapsed")); + expect(screen.getByText("*")).toBeInTheDocument(); + expect(screen.queryByText("https://a.com/*")).not.toBeInTheDocument(); + }); + + it("有变动时不折叠整卡", () => { + const changedRow: PermissionRow = { + kind: "connect", + risk: "warn", + values: ["api.a.com"], + sensitive: [], + diff: { added: ["api.a.com"], removed: [] }, + }; + render(); + expect(screen.queryByTestId("permission-card-collapsed")).not.toBeInTheDocument(); + }); + + it("全新安装不折叠整卡", () => { + render(); + expect(screen.queryByTestId("permission-card-collapsed")).not.toBeInTheDocument(); + }); + + it("移动端零变化同样折叠为单行", () => { + mobile = true; + render(); + expect(screen.getByTestId("permission-card-collapsed")).toBeInTheDocument(); + expect(screen.queryByText("api.a.com")).not.toBeInTheDocument(); + }); +}); diff --git a/src/pages/install/components/PermissionCard.tsx b/src/pages/install/components/PermissionCard.tsx index 2115a21f6..c989d5be9 100644 --- a/src/pages/install/components/PermissionCard.tsx +++ b/src/pages/install/components/PermissionCard.tsx @@ -1,15 +1,20 @@ +import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { ShieldCheck } from "lucide-react"; +import { ChevronDown, ShieldCheck } from "lucide-react"; import { cn } from "@App/pkg/utils/cn"; import { useIsMobile } from "@App/pages/components/use-is-mobile"; import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from "@App/pages/components/ui/accordion"; -import type { PermissionRow as PermissionRowData } from "../permissions"; -import { PermissionRow, PermissionChips, KIND_META, RISK_STYLE } from "./PermissionRow"; +import { isPermissionChanged, type PermissionRow as PermissionRowData } from "../permissions"; +import { PermissionRow, PermissionChips, PermissionDelta, NoChangeTag, KIND_META, RISK_STYLE } from "./PermissionRow"; function MobilePermissions({ rows }: { rows: PermissionRowData[] }) { const { t } = useTranslation(["install", "common"]); - // 默认仅展开高风险(danger)项 - const defaultValue = rows.filter((r) => r.risk === "danger").map((r) => r.kind); + // 有变动时默认只展开有变动的类别;全新安装、以及用户主动点开的零变化整卡都退回只展开高风险项, + // 否则零变化整卡展开后每一类都是收起的,「点开即得到全量清单」在移动端会落空。 + const hasChanged = rows.some(isPermissionChanged); + const defaultValue = rows + .filter((r) => (hasChanged ? isPermissionChanged(r) : r.risk === "danger")) + .map((r) => r.kind); return ( @@ -27,6 +32,8 @@ function MobilePermissions({ rows }: { rows: PermissionRowData[] }) { {row.values.length} + + {row.diff && !isPermissionChanged(row) && } @@ -39,16 +46,111 @@ function MobilePermissions({ rows }: { rows: PermissionRowData[] }) { ); } -export function PermissionCard({ rows }: { rows: PermissionRowData[] }) { +/** 未变动类别的单行形态:名称、计数与「无变化」,点开即还原成完整权限行 */ +function CollapsedRow({ row }: { row: PermissionRowData }) { + const { t } = useTranslation(["install", "common"]); + const [open, setOpen] = useState(false); + const { icon: Icon, labelKey } = KIND_META[row.kind]; + const style = RISK_STYLE[row.risk]; + + if (open) return ; + + return ( + + ); +} + +/** + * 权限一项没变时的整卡单行形态。 + * 折叠的是注意力而不是信息:类别数与取值总数就在行上,点开即得到与全新安装一致的全量清单。 + */ +function CollapsedCard({ + rows, + baselineVersion, + onExpand, +}: { + rows: PermissionRowData[]; + baselineVersion: string; + onExpand: () => void; +}) { + const { t } = useTranslation(["install", "common"]); + const total = rows.reduce((n, r) => n + r.values.length, 0); + + return ( +
+ {/* 不加 aria-label:它会盖掉「无变化 / 与旧版相同 / N 类 M 项」这三段摘要, + 令辅助技术只听到一句「展开」——折叠的就从注意力变成了信息 */} + +
+ ); +} + +export function PermissionCard({ rows, baselineVersion }: { rows: PermissionRowData[]; baselineVersion?: string }) { const { t } = useTranslation(["install", "common"]); const isMobile = useIsMobile(); + const [expanded, setExpanded] = useState(false); + // 一项权限都没有的更新无物可比,沿用全新安装的空态卡头与文案 + const isUpdate = baselineVersion !== undefined && rows.length > 0; + const changed = rows.filter(isPermissionChanged); + const added = changed.reduce((n, r) => n + r.diff!.added.length, 0); + const removed = changed.reduce((n, r) => n + r.diff!.removed.length, 0); + const noChange = isUpdate && changed.length === 0; + + if (noChange && !expanded) { + return setExpanded(true)} />; + } return (
-

{t("install:perm_card_title")}

- {t("install:perm_card_hint")} +

+ {changed.length ? t("install:perm_card_title_changed") : t("install:perm_card_title")} +

+ {changed.length > 0 && ( + + {added > 0 && {`+${added}`}} + {removed > 0 && {`−${removed}`}} + + )} + {isUpdate && !changed.length && } + + {isUpdate ? t("install:perm_card_compare", { version: baselineVersion }) : t("install:perm_card_hint")} +
{rows.length === 0 ? ( @@ -58,7 +160,12 @@ export function PermissionCard({ rows }: { rows: PermissionRowData[] }) { ) : ( rows.map((row, i) => (
0 && "border-t border-border")}> - + {/* 有变动时未变动的类别塌成单行让位;整卡零变化时用户是主动点开的,给全量 */} + {changed.length > 0 && !isPermissionChanged(row) ? ( + + ) : ( + + )}
)) )} diff --git a/src/pages/install/components/PermissionRow.test.tsx b/src/pages/install/components/PermissionRow.test.tsx index 15a927e9d..36b11c75a 100644 --- a/src/pages/install/components/PermissionRow.test.tsx +++ b/src/pages/install/components/PermissionRow.test.tsx @@ -69,3 +69,83 @@ describe("PermissionRow 权限行", () => { expect(within(row).queryByTestId("permission-more")).not.toBeInTheDocument(); }); }); + +describe("PermissionRow 更新差异态", () => { + const changed = { + kind: "connect" as const, + risk: "danger" as const, + values: ["*", "api.a.com", "api.b.com"], + sensitive: [], + diff: { added: ["*"], removed: ["old.a.com"] }, + }; + + it("新增与移除的取值常驻可见,未变动的收进折叠桶", () => { + render(); + const row = screen.getByTestId("permission-row"); + expect(within(row).getByText("*")).toBeInTheDocument(); + expect(within(row).getByText("old.a.com")).toBeInTheDocument(); + expect(within(row).queryByText("api.a.com")).not.toBeInTheDocument(); + expect(within(row).getByTestId("permission-more")).toHaveTextContent("未变动 2 项"); + }); + + it("变动状态标在 chip 上,且新增/移除带可读状态文案而不只靠颜色", () => { + render(); + const row = screen.getByTestId("permission-row"); + expect(within(row).getByText("*").closest("[data-chip]")).toHaveAttribute("data-change", "added"); + expect(within(row).getByText("old.a.com").closest("[data-chip]")).toHaveAttribute("data-change", "removed"); + expect(within(row).getByText("新增")).toBeInTheDocument(); + expect(within(row).getByText("已移除")).toBeInTheDocument(); + }); + + it("展开折叠桶后未变动取值可见并标记为 unchanged", () => { + render(); + const row = screen.getByTestId("permission-row"); + fireEvent.click(within(row).getByTestId("permission-more")); + expect(within(row).getByText("api.a.com").closest("[data-chip]")).toHaveAttribute("data-change", "unchanged"); + expect(within(row).queryByTestId("permission-more")).not.toBeInTheDocument(); + }); + + it("行内变动计数按新增与移除分别呈现", () => { + render(); + const row = screen.getByTestId("permission-row"); + expect(within(row).getByTestId("permission-delta")).toHaveTextContent("+1"); + expect(within(row).getByTestId("permission-delta")).toHaveTextContent("−1"); + }); + + it("差异为空的行不渲染变动计数,取值全部按未变动呈现", () => { + render( + + ); + const row = screen.getByTestId("permission-row"); + expect(within(row).queryByTestId("permission-delta")).not.toBeInTheDocument(); + expect(within(row).getByText("a").closest("[data-chip]")).toHaveAttribute("data-change", "unchanged"); + }); +}); + +describe("PermissionRow 零变动行的取值折叠", () => { + const manyUnchanged = { + kind: "match" as const, + risk: "normal" as const, + values: ["https://a.com/*", "https://b.com/*", "https://c.com/*", "https://d.com/*"], + sensitive: [], + diff: { added: [], removed: [] }, + }; + + it("没有可钉住的增删时,取值仍按 maxVisible 折叠为 +N", () => { + render(); + const row = screen.getByTestId("permission-row"); + expect(within(row).getByText("https://a.com/*")).toBeInTheDocument(); + expect(within(row).queryByText("https://c.com/*")).not.toBeInTheDocument(); + expect(within(row).getByTestId("permission-more")).toHaveTextContent("+2"); + }); + + it("点开 +N 后其余取值可见", () => { + render(); + const row = screen.getByTestId("permission-row"); + fireEvent.click(within(row).getByTestId("permission-more")); + expect(within(row).getByText("https://d.com/*")).toBeInTheDocument(); + expect(within(row).queryByTestId("permission-more")).not.toBeInTheDocument(); + }); +}); diff --git a/src/pages/install/components/PermissionRow.tsx b/src/pages/install/components/PermissionRow.tsx index bc18da4c2..c83d703fe 100644 --- a/src/pages/install/components/PermissionRow.tsx +++ b/src/pages/install/components/PermissionRow.tsx @@ -2,7 +2,12 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; import { Globe, ArrowLeftRight, ChevronDown, KeyRound, Package, TriangleAlert, type LucideIcon } from "lucide-react"; import { cn } from "@App/pkg/utils/cn"; -import type { PermissionKind, PermissionRisk, PermissionRow as PermissionRowData } from "../permissions"; +import { + isPermissionChanged, + type PermissionKind, + type PermissionRisk, + type PermissionRow as PermissionRowData, +} from "../permissions"; export const KIND_META: Record = { match: { icon: Globe, labelKey: "install:perm_match_label", summaryKey: "install:perm_match_summary" }, @@ -35,7 +40,64 @@ export const RISK_STYLE: Record = { + added: "install:perm_change_added", + removed: "install:perm_change_removed", + unchanged: "install:perm_change_unchanged", +}; + +function Chip({ value, row, change }: { value: string; row: PermissionRowData; change?: ChangeState }) { + const { t } = useTranslation(["install", "common"]); + const isSensitive = row.sensitive.includes(value); + const base = isSensitive ? "border border-warning-fg bg-muted text-warning-fg" : RISK_STYLE[row.risk].chip; + + // 变动状态只用字重、描边与 +/− 记号表达,底色继续留给风险等级—— + // 否则新增的 @connect * 会被染成「新增色」,把最该报警的情况伪装成安全的。 + const changeClass = + change === "removed" + ? "border border-border bg-transparent text-muted-foreground line-through" + : change === "unchanged" + ? "bg-muted border border-border text-muted-foreground" + : change === "added" + ? cn(base, "font-semibold", row.risk === "danger" && !isSensitive ? "border-destructive" : "border-current") + : base; + + return ( + + {change === "added" && } + {change === "removed" && } + {isSensitive && change !== "removed" && } + {change && {t(CHANGE_LABEL_KEY[change])}} + {value} + + ); +} + +function MoreButton({ label, onClick }: { label: string; onClick: () => void }) { + return ( + + ); +} + +/** + * 权限取值 chip 列表;桌面行与移动 Accordion 共用。 + * 全新安装按可见项 + 折叠的 +N 呈现;更新场景把新增与移除常驻在前,未变动项收进「未变动 N 项」的折叠桶。 + */ export function PermissionChips({ row, maxVisible = DEFAULT_MAX_VISIBLE, @@ -43,46 +105,74 @@ export function PermissionChips({ row: PermissionRowData; maxVisible?: number; }) { - const style = RISK_STYLE[row.risk]; - const sensitive = new Set(row.sensitive); + const { t } = useTranslation(["install", "common"]); const [expanded, setExpanded] = useState(false); - const visible = expanded ? row.values : row.values.slice(0, maxVisible); - const hidden = row.values.length - visible.length; + + if (!row.diff) { + const visible = expanded ? row.values : row.values.slice(0, maxVisible); + const hidden = row.values.length - visible.length; + return ( +
+ {visible.map((v) => ( + + ))} + {hidden > 0 && setExpanded(true)} />} +
+ ); + } + + const { added, removed } = row.diff; + const addedSet = new Set(added); + const unchanged = row.values.filter((v) => !addedSet.has(v)); + const pinned = added.length + removed.length; + // 有增删时未变动项整体让位给折叠桶;一项没变时没有可钉住的内容,桶会变成必须点开才能看到全部的空壳, + // 故退回全新安装的 maxVisible 截断——否则几十条 @match 的脚本一更新就会整片摊开。 + const visibleUnchanged = expanded ? unchanged : pinned > 0 ? [] : unchanged.slice(0, maxVisible); + const hidden = unchanged.length - visibleUnchanged.length; return (
- {visible.map((v) => { - const isSensitive = sensitive.has(v); - return ( - - {isSensitive && } - {v} - - ); - })} + {added.map((v) => ( + + ))} + {removed.map((v) => ( + + ))} + {visibleUnchanged.map((v) => ( + + ))} {hidden > 0 && ( - + /> )}
); } +/** 无变化标记;更新场景用来把「这一类你上次已经确认过」说出来 */ +export function NoChangeTag() { + const { t } = useTranslation(["install", "common"]); + return ( + + {t("install:perm_no_change")} + + ); +} + +/** 行头的变动计数;沿用代码卡 +N −M 的记号,无变动时不渲染 */ +export function PermissionDelta({ row }: { row: PermissionRowData }) { + if (!isPermissionChanged(row)) return null; + const { added, removed } = row.diff!; + return ( + + {added.length > 0 && {`+${added.length}`}} + {removed.length > 0 && {`−${removed.length}`}} + + ); +} + export function PermissionRow({ row, maxVisible }: { row: PermissionRowData; maxVisible?: number }) { const { t } = useTranslation(["install", "common"]); const { icon: Icon, labelKey, summaryKey } = KIND_META[row.kind]; @@ -97,6 +187,8 @@ export function PermissionRow({ row, maxVisible }: { row: PermissionRowData; max
{t(labelKey)} {row.values.length} + + {row.diff && !isPermissionChanged(row) && } {t(summaryKey)}
diff --git a/src/pages/install/permissions.test.ts b/src/pages/install/permissions.test.ts index 3484d7691..bdf0ed955 100644 --- a/src/pages/install/permissions.test.ts +++ b/src/pages/install/permissions.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; import type { SCMetadata } from "@App/app/repo/metadata"; -import { derivePermissions } from "./permissions"; +import { derivePermissions, derivePermissionDiff } from "./permissions"; describe("derivePermissions 权限派生", () => { it("无任何权限元数据时返回空数组", () => { @@ -92,3 +92,73 @@ describe("derivePermissions 权限派生", () => { expect(rows.map((r) => r.risk)).toEqual(["danger", "warn", "normal", "normal"]); }); }); + +describe("derivePermissionDiff 权限差异派生", () => { + it("新增的取值被标进 diff.added,未变动的不进", () => { + const rows = derivePermissionDiff( + { connect: ["api.example.com"] }, + { connect: ["api.example.com", "cdn.example.com"] } + ); + const connect = rows.find((r) => r.kind === "connect")!; + expect(connect.diff!.added).toEqual(["cdn.example.com"]); + expect(connect.diff!.removed).toEqual([]); + }); + + it("旧版本有、新版本没有的取值进 diff.removed", () => { + const rows = derivePermissionDiff({ grant: ["GM_setValue", "GM_notification"] }, { grant: ["GM_setValue"] }); + const grant = rows.find((r) => r.kind === "grant")!; + expect(grant.diff!.removed).toEqual(["GM_notification"]); + expect(grant.values).toEqual(["GM_setValue"]); + }); + + it("权限完全一致时每行的 diff 增删都为空", () => { + const metadata: SCMetadata = { match: ["https://a.com/*"], grant: ["GM_setValue"] }; + const rows = derivePermissionDiff(metadata, metadata); + expect(rows).toHaveLength(2); + expect(rows.every((r) => r.diff!.added.length === 0 && r.diff!.removed.length === 0)).toBe(true); + }); + + it("类别计数取新版本取值,移除项不计入 values", () => { + const rows = derivePermissionDiff( + { grant: ["GM_setValue", "GM_notification"] }, + { grant: ["GM_setValue", "GM_cookie"] } + ); + const grant = rows.find((r) => r.kind === "grant")!; + expect(grant.values).toHaveLength(2); + expect(grant.values).not.toContain("GM_notification"); + }); + + it("类别在新版本被清空但旧版本非空时仍然成行,风险降为 normal", () => { + const rows = derivePermissionDiff({ connect: ["*"] }, {}); + const connect = rows.find((r) => r.kind === "connect")!; + expect(connect.values).toEqual([]); + expect(connect.diff!.removed).toEqual(["*"]); + expect(connect.risk).toBe("normal"); + }); + + it("新旧两版都没有的类别不成行", () => { + const rows = derivePermissionDiff({ match: ["https://a.com/*"] }, { match: ["https://a.com/*"] }); + expect(rows.map((r) => r.kind)).toEqual(["match"]); + }); + + it("有变动的类别排在未变动的高危类别之前", () => { + const rows = derivePermissionDiff( + { connect: ["*"], match: ["https://a.com/*"] }, + { connect: ["*"], match: ["https://a.com/*", "https://b.com/*"] } + ); + expect(rows.map((r) => r.kind)).toEqual(["match", "connect"]); + }); + + it("同为有变动时仍按风险排序", () => { + const rows = derivePermissionDiff({}, { connect: ["*"], match: ["https://a.com/*"] }); + expect(rows.map((r) => r.kind)).toEqual(["connect", "match"]); + }); + + it("新增的危险取值仍排在该行取值最前", () => { + const rows = derivePermissionDiff({ connect: ["api.example.com"] }, { connect: ["api.example.com", "*"] }); + const connect = rows.find((r) => r.kind === "connect")!; + expect(connect.values[0]).toBe("*"); + expect(connect.risk).toBe("danger"); + expect(connect.diff!.added).toEqual(["*"]); + }); +}); diff --git a/src/pages/install/permissions.ts b/src/pages/install/permissions.ts index 70b5c20ce..6962b79c1 100644 --- a/src/pages/install/permissions.ts +++ b/src/pages/install/permissions.ts @@ -4,12 +4,20 @@ export type PermissionRisk = "normal" | "warn" | "danger"; export type PermissionKind = "match" | "connect" | "grant" | "require"; +/** 相对已安装版本的取值增删;仅更新场景派生 */ +export interface PermissionDiff { + added: string[]; + removed: string[]; +} + export interface PermissionRow { kind: PermissionKind; risk: PermissionRisk; values: string[]; /** values 中被判定为敏感的子集(如 GM_cookie),用于额外高亮 */ sensitive: string[]; + /** 更新场景才有;全新安装为 undefined,此时整行按「全部都是新的」呈现 */ + diff?: PermissionDiff; } // 需要额外标记的敏感 GM 能力(可访问 Cookie 等隐私数据) @@ -20,6 +28,9 @@ const RISK_ORDER: Record = { normal: 2, }; +// 基础类别顺序:运行网站 → 跨域访问 → GM 能力 → 外部资源 +const KIND_ORDER: PermissionKind[] = ["match", "connect", "grant", "require"]; + function sortDangerConnectFirst(values: string[]): string[] { return [...values].sort((a, b) => Number(b === "*") - Number(a === "*")); } @@ -28,43 +39,75 @@ function sortSensitiveGrantFirst(values: string[]): string[] { return [...values].sort((a, b) => Number(SENSITIVE_GRANTS.has(b)) - Number(SENSITIVE_GRANTS.has(a))); } +/** 按类别摊平元数据中的权限取值,空类别保留为空数组以便与另一版本对齐比较 */ +function collectValues(metadata: SCMetadata): Record { + return { + match: [...(metadata.match || []), ...(metadata.include || [])], + connect: metadata.connect || [], + grant: (metadata.grant || []).filter((g) => g !== "none"), + require: [...(metadata.require || []), ...(metadata.resource || [])], + }; +} + +/** 由单个类别的取值构造权限行,决定行内排序、风险与敏感项 */ +function buildRow(kind: PermissionKind, values: string[]): PermissionRow { + // 取值为空意味着该类别什么都不请求,无论原本属于哪一档都不再构成风险 + if (!values.length) { + return { kind, risk: "normal", values: [], sensitive: [] }; + } + switch (kind) { + case "connect": { + const sorted = sortDangerConnectFirst(values); + return { kind, risk: sorted.includes("*") ? "danger" : "warn", values: sorted, sensitive: [] }; + } + case "grant": { + const sorted = sortSensitiveGrantFirst(values); + return { kind, risk: "warn", values: sorted, sensitive: sorted.filter((g) => SENSITIVE_GRANTS.has(g)) }; + } + default: + return { kind, risk: "normal", values, sensitive: [] }; + } +} + +/** 该行相对已安装版本是否发生了增删;全新安装(无 diff)恒为 false */ +export const isPermissionChanged = (row: PermissionRow) => + !!row.diff && (row.diff.added.length > 0 || row.diff.removed.length > 0); + /** * 把脚本元数据派生为「权限行」,作为安装页信任决策的核心呈现。 - * 高危/告警权限优先展示,同风险内保持基础类别顺序:运行网站 → 跨域访问 → GM 能力 → 外部资源。 + * 高危/告警权限优先展示,同风险内保持基础类别顺序。 */ export function derivePermissions(metadata: SCMetadata): PermissionRow[] { - const rows: PermissionRow[] = []; - - const match = [...(metadata.match || []), ...(metadata.include || [])]; - if (match.length) { - rows.push({ kind: "match", risk: "normal", values: match, sensitive: [] }); - } - - const connect = metadata.connect || []; - if (connect.length) { - const values = sortDangerConnectFirst(connect); - rows.push({ - kind: "connect", - risk: values.includes("*") ? "danger" : "warn", - values, - sensitive: [], - }); - } + const values = collectValues(metadata); + return KIND_ORDER.filter((kind) => values[kind].length) + .map((kind) => buildRow(kind, values[kind])) + .sort((a, b) => RISK_ORDER[a.risk] - RISK_ORDER[b.risk]); +} - const grant = sortSensitiveGrantFirst((metadata.grant || []).filter((g) => g !== "none")); - if (grant.length) { - rows.push({ - kind: "grant", - risk: "warn", - values: grant, - sensitive: grant.filter((g) => SENSITIVE_GRANTS.has(g)), - }); - } +/** + * 以已安装脚本为基线派生权限行,逐取值标注新增与移除。 + * 排序键为「有变动 → 风险 → 基础类别顺序」:更新页首先要回答的是这次变了什么, + * 未变动的高危项是用户上次安装时已经确认过的内容。 + * 某类别在新版本被清空但旧版本非空时仍然成行,否则「不再请求网络访问」这类变化会整行消失。 + */ +export function derivePermissionDiff(oldMetadata: SCMetadata, newMetadata: SCMetadata): PermissionRow[] { + const oldValues = collectValues(oldMetadata); + const newValues = collectValues(newMetadata); + const rows: PermissionRow[] = []; - const require = [...(metadata.require || []), ...(metadata.resource || [])]; - if (require.length) { - rows.push({ kind: "require", risk: "normal", values: require, sensitive: [] }); + for (const kind of KIND_ORDER) { + const next = newValues[kind]; + const prev = oldValues[kind]; + const nextSet = new Set(next); + const prevSet = new Set(prev); + const removed = prev.filter((v) => !nextSet.has(v)); + if (!next.length && !removed.length) continue; + const row = buildRow(kind, next); + row.diff = { added: row.values.filter((v) => !prevSet.has(v)), removed }; + rows.push(row); } - return rows.sort((a, b) => RISK_ORDER[a.risk] - RISK_ORDER[b.risk]); + return rows.sort( + (a, b) => Number(isPermissionChanged(b)) - Number(isPermissionChanged(a)) || RISK_ORDER[a.risk] - RISK_ORDER[b.risk] + ); } diff --git a/src/pages/install/useInstallData.test.ts b/src/pages/install/useInstallData.test.ts index 99a28eea6..caee21fd2 100644 --- a/src/pages/install/useInstallData.test.ts +++ b/src/pages/install/useInstallData.test.ts @@ -284,6 +284,42 @@ describe("useInstallData 数据流编排", () => { expect(state.view.oldCode).toBe("// old code"); }); + it("uuid 更新时以已安装脚本的 metadata 为基线派生权限差异", async () => { + window.history.replaceState({}, "", "/install.html?uuid=u1"); + const metadata = { name: ["示例脚本"], version: ["2.0.0"], connect: ["api.e.com", "cdn.e.com"] }; + const oldMetadata = { name: ["示例脚本"], version: ["1.0.0"], connect: ["api.e.com"] }; + const info: ScriptInfo = { + url: "https://e.com/x.user.js", + code: "", + uuid: "u1", + userSubscribe: false, + metadata, + source: "user", + }; + (scriptClient.getInstallInfo as Mock).mockResolvedValue([true, info, {}]); + (getTempCode as Mock).mockResolvedValue("// new code"); + (prepareScriptByCode as Mock).mockResolvedValue({ + script: { name: "示例脚本", metadata, status: SCRIPT_STATUS_ENABLE } as unknown as Script, + oldScript: { metadata: oldMetadata } as unknown as Script, + oldScriptCode: "// old code", + }); + + const { result } = renderHook(() => useInstallData()); + await waitFor(() => expect(result.current.state.status).toBe("ready")); + const state = result.current.state; + if (state.status !== "ready") throw new Error("not ready"); + const connect = state.view.permissions.find((r) => r.kind === "connect")!; + expect(connect.diff).toEqual({ added: ["cdn.e.com"], removed: [] }); + }); + + it("全新安装时权限行不携带差异", async () => { + const result = await setupReady(); + const state = result.current.state; + if (state.status !== "ready") throw new Error("not ready"); + expect(state.view.isUpdate).toBe(false); + expect(state.view.permissions.every((r) => r.diff === undefined)).toBe(true); + }); + describe("安装成功后离开安装页:独立新标签应关闭,网页链接接管的原标签应返回上一页", () => { it("独立新标签 history.length 为 1 时应使用 window.close() 关闭", async () => { const result = await setupReady(); diff --git a/src/pages/install/useInstallData.ts b/src/pages/install/useInstallData.ts index 080341ab2..64683a847 100644 --- a/src/pages/install/useInstallData.ts +++ b/src/pages/install/useInstallData.ts @@ -18,7 +18,7 @@ import { loadHandle } from "@App/pkg/utils/filehandle-db"; import { startFileTrack, unmountFileTrack, type FTInfo } from "@App/pkg/utils/file-tracker"; import { TempStorageDAO } from "@App/app/repo/tempStorage"; import { EnableAgent } from "@App/app/const"; -import { derivePermissions, type PermissionRow } from "./permissions"; +import { derivePermissions, derivePermissionDiff, type PermissionRow } from "./permissions"; import { deriveVersion, deriveAntifeatures, @@ -68,8 +68,10 @@ export function assembleInstallView(args: { code: string; oldVersion: string | null; oldCode?: string; + /** 已安装脚本的元数据,作为权限差异的基线;全新安装与订阅为 undefined */ + oldMetadata?: SCMetadata; }): InstallView { - const { isUpdate, inTrash, scriptInfo, action, code, oldVersion, oldCode } = args; + const { isUpdate, inTrash, scriptInfo, action, code, oldVersion, oldCode, oldMetadata } = args; const metadata = scriptInfo.metadata; const schedule = deriveScheduleInfo(metadata); return { @@ -82,7 +84,7 @@ export function assembleInstallView(args: { source: prettyUrl(scriptInfo.url), description: i18nDescription(action), version: deriveVersion(metadata.version?.[0], oldVersion), - permissions: derivePermissions(metadata), + permissions: oldMetadata ? derivePermissionDiff(oldMetadata, metadata) : derivePermissions(metadata), antifeatures: deriveAntifeatures(metadata), schedule, scheduleNextRun: schedule?.kind === "cron" ? nextTimeDisplay(schedule.expression) : undefined, @@ -253,6 +255,7 @@ export function useInstallData(): UseInstallData { let action: Script | Subscribe; let oldVersion: string | null; let oldCode: string | undefined; + let oldMetadata: SCMetadata | undefined; let inTrash = false; if (info.userSubscribe) { const p = await prepareSubscribeByCode(code, info.url); @@ -271,6 +274,7 @@ export function useInstallData(): UseInstallData { action = p.script; oldVersion = versionOf(p.oldScript); oldCode = p.oldScriptCode; + oldMetadata = p.oldScript?.metadata; inTrash = p.oldInTrash === true; } if (cancelled) return; @@ -288,6 +292,7 @@ export function useInstallData(): UseInstallData { code, oldVersion, oldCode, + oldMetadata, }), }); };