diff --git a/Packages/macOS/CmuxSettingsUI/Package.swift b/Packages/macOS/CmuxSettingsUI/Package.swift index 701b7f470db3..354b99ad3a1b 100644 --- a/Packages/macOS/CmuxSettingsUI/Package.swift +++ b/Packages/macOS/CmuxSettingsUI/Package.swift @@ -25,7 +25,8 @@ let package = Package( "CMUXMobileCore", .product(name: "CmuxFoundation", package: "CmuxFoundation"), .product(name: "CmuxSettings", package: "CmuxSettings"), - ] + ], + resources: [.process("Resources")] ), .testTarget( name: "CmuxSettingsUITests", diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/SettingsHostActions.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/SettingsHostActions.swift index 3015a93c8cb9..01ec8b047d23 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/SettingsHostActions.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/SettingsHostActions.swift @@ -35,6 +35,17 @@ public protocol SettingsHostActions: AnyObject { /// editor. func openConfigInExternalEditor() + /// Reads the existing config-backed automation rules for Settings status. + func automationRulesStatus() async -> AutomationRulesStatus + + /// Opens ~/.cmuxterm/automations.json in the user's preferred editor. + func openAutomationRulesInExternalEditor() + + /// Asks the running automation engine to reload its existing config file. + /// Returns false when the host has no live automation engine. + @discardableResult + func reloadAutomationRules() -> Bool + /// Launches the host's feedback flow (typically a "Send Feedback" /// URL or in-app form). func sendFeedback() @@ -287,6 +298,34 @@ public protocol SettingsHostActions: AnyObject { func openCloudMachinesBilling() } +/// Host-provided summary of the existing config-backed automation rules. +public struct AutomationRulesStatus: Equatable, Sendable { + public let configPath: String + public let ruleCount: Int + public let enabledCount: Int + public let configExists: Bool + public let hasError: Bool + + public init( + configPath: String, + ruleCount: Int, + enabledCount: Int, + configExists: Bool, + hasError: Bool = false + ) { + self.configPath = configPath + self.ruleCount = max(0, ruleCount) + self.enabledCount = min(max(0, enabledCount), max(0, ruleCount)) + self.configExists = configExists + self.hasError = hasError + } + + /// Number of configured rules that are currently disabled. + public var disabledCount: Int { + ruleCount - enabledCount + } +} + /// Snapshot of the caller's Cloud Machines plan for the settings section. public struct CloudMachinesPlanSummary: Equatable, Sendable { public let planLabel: String @@ -341,6 +380,23 @@ public extension SettingsHostActions { /// Validates a candidate custom notification sound path on the host. func validateNotificationSoundFile(path: String) async -> Bool { false } + /// Empty automation summary for previews and package-only hosts. + func automationRulesStatus() async -> AutomationRulesStatus { + AutomationRulesStatus( + configPath: "~/.cmuxterm/automations.json", + ruleCount: 0, + enabledCount: 0, + configExists: false + ) + } + + /// Default no-op for hosts without app-owned automation files. + func openAutomationRulesInExternalEditor() {} + + /// Default failure for hosts without a live automation engine. + @discardableResult + func reloadAutomationRules() -> Bool { false } + /// Default no-op for previews and tests without a live control socket. func socketControlConfigurationDidChange() {} diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Resources/Localizable.xcstrings b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Resources/Localizable.xcstrings new file mode 100644 index 000000000000..413438f2149f --- /dev/null +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Resources/Localizable.xcstrings @@ -0,0 +1,1370 @@ +{ + "sourceLanguage": "en", + "strings": { + "settings.automation.rules": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "قواعد الأتمتة" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Pravila automatizacije" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Automatiseringsregler" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Automatisierungsregeln" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Automation Rules" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Reglas de automatización" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Règles d’automatisation" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Regole di automazione" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "自動化ルール" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ច្បាប់ស្វ័យប្រវត្តិកម្ម" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "자동화 규칙" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Automatiseringsregler" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Reguły automatyzacji" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Regras de automação" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Правила автоматизации" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "กฎการทำงานอัตโนมัติ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Otomasyon Kuralları" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Правила автоматизації" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自动化规则" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自動化規則" + } + } + } + }, + "settings.automation.rules.counts": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "%1$lld إجمالي • %2$lld مفعّلة • %3$lld معطّلة" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "%1$lld ukupno • %2$lld omogućeno • %3$lld onemogućeno" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "%1$lld i alt • %2$lld aktiveret • %3$lld deaktiveret" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%1$lld gesamt • %2$lld aktiviert • %3$lld deaktiviert" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$lld total • %2$lld enabled • %3$lld disabled" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%1$lld en total • %2$lld activadas • %3$lld desactivadas" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%1$lld au total • %2$lld activées • %3$lld désactivées" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "%1$lld totali • %2$lld abilitate • %3$lld disabilitate" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "合計 %1$lld • 有効 %2$lld • 無効 %3$lld" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "សរុប %1$lld • បានបើក %2$lld • បានបិទ %3$lld" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "총 %1$lld개 • 활성 %2$lld개 • 비활성 %3$lld개" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "%1$lld totalt • %2$lld aktivert • %3$lld deaktivert" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Łącznie %1$lld • włączone %2$lld • wyłączone %3$lld" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "%1$lld no total • %2$lld ativadas • %3$lld desativadas" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Всего %1$lld • включено %2$lld • отключено %3$lld" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ทั้งหมด %1$lld • เปิดใช้ %2$lld • ปิดใช้ %3$lld" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Toplam %1$lld • %2$lld etkin • %3$lld devre dışı" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Усього %1$lld • увімкнено %2$lld • вимкнено %3$lld" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "共 %1$lld 条 • 已启用 %2$lld 条 • 已禁用 %3$lld 条" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "共 %1$lld 條 • 已啟用 %2$lld 條 • 已停用 %3$lld 條" + } + } + } + }, + "settings.automation.rules.edit": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تحرير القواعد" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Uredi pravila" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Rediger regler" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Regeln bearbeiten" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Edit Rules" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Editar reglas" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Modifier les règles" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Modifica regole" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ルールを編集" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "កែសម្រួលច្បាប់" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "규칙 편집" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Rediger regler" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Edytuj reguły" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Editar regras" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Редактировать правила" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แก้ไขกฎ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kuralları Düzenle" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Редагувати правила" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "编辑规则" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "編輯規則" + } + } + } + }, + "settings.automation.rules.empty": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لم يتم تكوين أي قواعد بعد." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Još nema konfiguriranih pravila." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Ingen regler er konfigureret endnu." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Noch keine Regeln konfiguriert." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No rules configured yet." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Aún no hay reglas configuradas." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucune règle configurée pour l’instant." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Nessuna regola configurata." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "設定済みのルールはまだありません。" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "មិនទាន់មានច្បាប់ដែលបានកំណត់ទេ។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아직 구성된 규칙이 없습니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Ingen regler er konfigurert ennå." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nie skonfigurowano jeszcze żadnych reguł." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Nenhuma regra configurada ainda." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Правила ещё не настроены." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ยังไม่ได้กำหนดกฎ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Henüz yapılandırılmış kural yok." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Правила ще не налаштовані." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "尚未配置规则。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "尚未設定規則。" + } + } + } + }, + "settings.automation.rules.error": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذر تحميل ملف قواعد الأتمتة. حرّر ملف JSON ثم أعد التحميل." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Datoteku pravila automatizacije nije moguće učitati. Uredite JSON datoteku i ponovo učitajte." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Automatiseringsregelfilen kunne ikke indlæses. Rediger JSON-filen, og genindlæs." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Datei mit den Automatisierungsregeln konnte nicht geladen werden. Bearbeite die JSON-Datei und lade sie neu." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "The automation rules file could not be loaded. Edit the JSON file and reload." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo cargar el archivo de reglas de automatización. Edita el archivo JSON y vuelve a cargarlo." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible de charger le fichier de règles d’automatisation. Modifiez le fichier JSON puis rechargez." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Impossibile caricare il file delle regole di automazione. Modifica il file JSON e ricarica." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "自動化ルールファイルを読み込めませんでした。JSON ファイルを編集して再読み込みしてください。" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "មិនអាចផ្ទុកឯកសារច្បាប់ស្វ័យប្រវត្តិកម្មបានទេ។ កែសម្រួលឯកសារ JSON ហើយផ្ទុកឡើងវិញ។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "자동화 규칙 파일을 불러올 수 없습니다. JSON 파일을 편집한 후 다시 로드하세요." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Filen med automatiseringsregler kunne ikke lastes inn. Rediger JSON-filen og last inn på nytt." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nie udało się wczytać pliku reguł automatyzacji. Edytuj plik JSON i przeładuj." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Não foi possível carregar o arquivo de regras de automação. Edite o arquivo JSON e recarregue." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось загрузить файл правил автоматизации. Отредактируйте JSON-файл и перезагрузите." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไม่สามารถโหลดไฟล์กฎการทำงานอัตโนมัติได้ แก้ไขไฟล์ JSON แล้วโหลดใหม่" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Otomasyon kuralları dosyası yüklenemedi. JSON dosyasını düzenleyip yeniden yükleyin." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Не вдалося завантажити файл правил автоматизації. Відредагуйте JSON-файл і перезавантажте." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法加载自动化规则文件。请编辑 JSON 文件后重新加载。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法載入自動化規則檔案。請編輯 JSON 檔案後重新載入。" + } + } + } + }, + "settings.automation.rules.loading": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "جارٍ تحميل القواعد…" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Učitavanje pravila…" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Indlæser regler…" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Regeln werden geladen…" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Loading rules…" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cargando reglas…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Chargement des règles…" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Caricamento regole…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ルールを読み込み中…" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "កំពុងផ្ទុកច្បាប់…" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "규칙 불러오는 중…" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Laster inn regler…" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wczytywanie reguł…" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Carregando regras…" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Загрузка правил…" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "กำลังโหลดกฎ…" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kurallar yükleniyor…" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Завантаження правил…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在加载规则…" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在載入規則…" + } + } + } + }, + "settings.automation.rules.missing": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لا يوجد ملف قواعد بعد." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Još nema datoteke pravila." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Ingen regelfil endnu." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Noch keine Regeldatei." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No rules file yet." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Aún no hay archivo de reglas." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucun fichier de règles pour l’instant." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Nessun file di regole." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ルールファイルはまだありません。" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "មិនទាន់មានឯកសារច្បាប់ទេ។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아직 규칙 파일이 없습니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Ingen regelfil ennå." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nie ma jeszcze pliku reguł." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Nenhum arquivo de regras ainda." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Файла правил ещё нет." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ยังไม่มีไฟล์กฎ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Henüz kural dosyası yok." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Файлу правил ще немає." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "尚无规则文件。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "尚無規則檔案。" + } + } + } + }, + "settings.automation.rules.note": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "توجد القواعد في ~/.cmuxterm/automations.json. حرّر ملف JSON مباشرة، ثم أعد تحميل المحرك قيد التشغيل. تظل واجهة سطر الأوامر `cmux automation` متاحة للاختبار وتشخيص السجلات." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Pravila se nalaze u ~/.cmuxterm/automations.json. Uredite JSON direktno, zatim ponovo učitajte pokrenuti mehanizam. CLI `cmux automation` ostaje dostupan za testiranje i dijagnostiku zapisa." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Regler findes i ~/.cmuxterm/automations.json. Rediger JSON-filen direkte, og genindlæs derefter den kørende motor. CLI'en `cmux automation` er fortsat tilgængelig til test og logdiagnostik." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Regeln befinden sich in ~/.cmuxterm/automations.json. Bearbeite die JSON-Datei direkt und lade anschließend die laufende Engine neu. Die CLI `cmux automation` bleibt für Tests und Logdiagnosen verfügbar." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Rules live in ~/.cmuxterm/automations.json. Edit the JSON directly, then reload the running engine. The cmux automation CLI remains available for test and log diagnostics." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Las reglas se guardan en ~/.cmuxterm/automations.json. Edita el JSON directamente y después recarga el motor en ejecución. La CLI `cmux automation` sigue disponible para pruebas y diagnóstico de registros." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Les règles se trouvent dans ~/.cmuxterm/automations.json. Modifiez directement le fichier JSON, puis rechargez le moteur en cours d’exécution. La CLI `cmux automation` reste disponible pour les tests et le diagnostic des journaux." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Le regole si trovano in ~/.cmuxterm/automations.json. Modifica direttamente il JSON, quindi ricarica il motore in esecuzione. La CLI `cmux automation` resta disponibile per test e diagnostica dei log." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ルールは ~/.cmuxterm/automations.json にあります。JSON を直接編集してから、実行中のエンジンを再読み込みしてください。テストやログ診断には引き続き `cmux automation` CLI を使用できます。" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ច្បាប់ស្ថិតនៅក្នុង ~/.cmuxterm/automations.json។ កែសម្រួល JSON ដោយផ្ទាល់ រួចផ្ទុកម៉ាស៊ីនដែលកំពុងដំណើរការឡើងវិញ។ CLI `cmux automation` នៅតែអាចប្រើសម្រាប់ការសាកល្បង និងវិនិច្ឆ័យកំណត់ហេតុ។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "규칙은 ~/.cmuxterm/automations.json에 있습니다. JSON을 직접 편집한 다음 실행 중인 엔진을 다시 로드하세요. 테스트와 로그 진단에는 `cmux automation` CLI를 계속 사용할 수 있습니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Reglene ligger i ~/.cmuxterm/automations.json. Rediger JSON-filen direkte, og last deretter inn den kjørende motoren på nytt. CLI-en `cmux automation` er fortsatt tilgjengelig for testing og loggdiagnostikk." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Reguły znajdują się w ~/.cmuxterm/automations.json. Edytuj JSON bezpośrednio, a następnie przeładuj działający silnik. Interfejs CLI `cmux automation` pozostaje dostępny do testów i diagnostyki logów." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "As regras ficam em ~/.cmuxterm/automations.json. Edite o JSON diretamente e depois recarregue o mecanismo em execução. A CLI `cmux automation` continua disponível para testes e diagnóstico de logs." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Правила находятся в ~/.cmuxterm/automations.json. Отредактируйте JSON напрямую, затем перезагрузите работающий движок. CLI `cmux automation` по-прежнему доступен для тестирования и диагностики журналов." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "กฎอยู่ที่ ~/.cmuxterm/automations.json แก้ไข JSON โดยตรง แล้วโหลดเอนจินที่กำลังทำงานใหม่ CLI `cmux automation` ยังใช้สำหรับทดสอบและวินิจฉัยบันทึกได้" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kurallar ~/.cmuxterm/automations.json içinde bulunur. JSON'u doğrudan düzenleyin, ardından çalışan motoru yeniden yükleyin. `cmux automation` CLI'si test ve günlük tanılama için kullanılmaya devam eder." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Правила містяться в ~/.cmuxterm/automations.json. Відредагуйте JSON безпосередньо, а потім перезавантажте запущений рушій. CLI `cmux automation` залишається доступним для тестування та діагностики журналів." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "规则位于 ~/.cmuxterm/automations.json。直接编辑 JSON,然后重新加载正在运行的引擎。`cmux automation` CLI 仍可用于测试和日志诊断。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "規則位於 ~/.cmuxterm/automations.json。直接編輯 JSON,然後重新載入正在執行的引擎。`cmux automation` CLI 仍可用於測試與記錄診斷。" + } + } + } + }, + "settings.automation.rules.reload": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعادة التحميل" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ponovo učitaj" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Genindlæs" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neu laden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Reload" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Recargar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Recharger" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Ricarica" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "再読み込み" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ផ្ទុកឡើងវិញ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다시 로드" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Last inn på nytt" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przeładuj" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Recarregar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Перезагрузить" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "โหลดใหม่" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yeniden Yükle" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Перезавантажити" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重新加载" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新載入" + } + } + } + }, + "settings.automation.rules.reload.requested": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تم طلب إعادة التحميل." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Zatraženo je ponovno učitavanje." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Genindlæsning anmodet." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neuladen angefordert." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Reload requested." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Recarga solicitada." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Rechargement demandé." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Ricaricamento richiesto." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "再読み込みを要求しました。" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "បានស្នើឱ្យផ្ទុកឡើងវិញ។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다시 로드를 요청했습니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Ny innlasting forespurt." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zażądano przeładowania." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Recarga solicitada." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Запрошена перезагрузка." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ขอให้โหลดใหม่แล้ว" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yeniden yükleme istendi." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Запит на перезавантаження надіслано." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已请求重新加载。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已要求重新載入。" + } + } + } + }, + "settings.automation.rules.reload.unavailable": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "محرك الأتمتة غير متاح." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Mehanizam automatizacije nije dostupan." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Automatiseringsmotoren er ikke tilgængelig." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Automatisierungs-Engine nicht verfügbar." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Automation engine unavailable." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El motor de automatización no está disponible." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Moteur d’automatisation indisponible." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Motore di automazione non disponibile." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "自動化エンジンを利用できません。" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ម៉ាស៊ីនស្វ័យប្រវត្តិកម្មមិនអាចប្រើបានទេ។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "자동화 엔진을 사용할 수 없습니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Automatiseringsmotoren er utilgjengelig." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Silnik automatyzacji jest niedostępny." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "O mecanismo de automação não está disponível." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Движок автоматизации недоступен." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เอนจินการทำงานอัตโนมัติไม่พร้อมใช้งาน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Otomasyon motoru kullanılamıyor." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Рушій автоматизації недоступний." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自动化引擎不可用。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自動化引擎無法使用。" + } + } + } + } + }, + "version": "1.0" +} diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AutomationSection.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AutomationSection.swift index 2b0e91f9b978..681b7edbf1e8 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AutomationSection.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AutomationSection.swift @@ -30,6 +30,11 @@ public struct AutomationSection: View { @State private var showOpenAccessConfirmation: Bool = false @State private var pendingOpenAccessMode: SocketControlMode? @State private var modeBeforePendingOpenAccess: SocketControlMode? + @State private var automationRulesStatus: AutomationRulesStatus? + @State private var automationRulesActionMessage: String? + @State private var automationRulesActionIsError = false + @State private var automationRulesRefreshID = 0 + private struct SocketPasswordStatus: Equatable { let message: String let isError: Bool @@ -81,6 +86,7 @@ public struct AutomationSection: View { Group { SettingsSectionHeader(String(localized: "settings.section.automation", defaultValue: "Automation"), section: .automation) socketControlCard + automationRulesCard claudeCodeCard codexCard claudePathCard @@ -122,6 +128,106 @@ public struct AutomationSection: View { localized: "settings.automation.openAccess.dialog.message", defaultValue: "This disables ancestry and password checks and opens the socket to all local users. Only enable when you understand the risk." )) + } + .task { + startSettingsObservation([socketPasswordModel, modeModel, claudeCodeModel, codexModel, claudePathModel, autoNamingModel, autoNamingAgentModel, autoNamingStatusModel, ripgrepPathModel, suppressSubagentModel, ampModel, cursorModel, geminiModel, kiroModel, kiroLevelModel, portBaseModel, portRangeModel]) + } + .task(id: automationRulesRefreshID) { + await refreshAutomationRulesStatus() + } + .task { + for await _ in ManagedDevicePolicy.changeSignals() { + socketPolicyResolution = socketPolicyResolver.resolve() + } + } + } + + /// Thin native exposure of the existing JSON-backed automation engine. + @ViewBuilder + private var automationRulesCard: some View { + SettingsCard { + SettingsCardRow( + configurationReview: .action, + String(localized: "settings.automation.rules", defaultValue: "Automation Rules", bundle: .module), + subtitle: automationRulesSubtitle + ) { + HStack(spacing: 8) { + Button(String(localized: "settings.automation.rules.edit", defaultValue: "Edit Rules", bundle: .module)) { + automationRulesActionMessage = nil + hostActions.openAutomationRulesInExternalEditor() + automationRulesRefreshID += 1 + } + .buttonStyle(.bordered) + .controlSize(.small) + .accessibilityIdentifier("SettingsAutomationRulesEditButton") + + Button(String(localized: "settings.automation.rules.reload", defaultValue: "Reload", bundle: .module)) { + let didRequestReload = hostActions.reloadAutomationRules() + automationRulesActionIsError = !didRequestReload + automationRulesActionMessage = didRequestReload + ? String(localized: "settings.automation.rules.reload.requested", defaultValue: "Reload requested.", bundle: .module) + : String(localized: "settings.automation.rules.reload.unavailable", defaultValue: "Automation engine unavailable.", bundle: .module) + automationRulesRefreshID += 1 + } + .buttonStyle(.bordered) + .controlSize(.small) + .accessibilityIdentifier("SettingsAutomationRulesReloadButton") + } + } + .accessibilityIdentifier("SettingsAutomationRulesStatus") + + SettingsCardDivider() + SettingsCardNote(String( + localized: "settings.automation.rules.note", + defaultValue: "Rules live in ~/.cmuxterm/automations.json. Edit the JSON directly, then reload the running engine. The cmux automation CLI remains available for test and log diagnostics.", + bundle: .module + )) + + if let automationRulesActionMessage { + SettingsCardDivider() + Text(automationRulesActionMessage) + .cmuxFont(.caption) + .foregroundStyle(automationRulesActionIsError ? Color.red : Color.secondary) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .accessibilityIdentifier("SettingsAutomationRulesActionStatus") + } + } + } + + /// Current rule-count or configuration-state summary shown under the card title. + private var automationRulesSubtitle: String { + guard let status = automationRulesStatus else { + return String(localized: "settings.automation.rules.loading", defaultValue: "Loading rules…", bundle: .module) + } + if status.hasError { + return String( + localized: "settings.automation.rules.error", + defaultValue: "The automation rules file could not be loaded. Edit the JSON file and reload.", + bundle: .module + ) + } + if status.ruleCount == 0 { + return status.configExists + ? String(localized: "settings.automation.rules.empty", defaultValue: "No rules configured yet.", bundle: .module) + : String(localized: "settings.automation.rules.missing", defaultValue: "No rules file yet.", bundle: .module) + } + let format = String( + localized: "settings.automation.rules.counts", + defaultValue: "%1$lld total • %2$lld enabled • %3$lld disabled", + bundle: .module + ) + return String.localizedStringWithFormat( + format, + Int64(status.ruleCount), + Int64(status.enabledCount), + Int64(status.disabledCount) + ) + } + + /// Refreshes the card from the authoritative config store through the host bridge. + private func refreshAutomationRulesStatus() async { + automationRulesStatus = await hostActions.automationRulesStatus() }.task { startSettingsObservation([socketPasswordModel, modeModel, claudeCodeModel, codexModel, claudePathModel, autoNamingModel, autoNamingAgentModel, autoNamingStatusModel, ripgrepPathModel, suppressSubagentModel, ampModel, cursorModel, geminiModel, kiroModel, kiroLevelModel, portBaseModel, portRangeModel]) } .task { for await _ in ManagedDevicePolicy.changeSignals() { diff --git a/Sources/HostSettingsActions.swift b/Sources/HostSettingsActions.swift index 0165d7289abf..6c277df21ab7 100644 --- a/Sources/HostSettingsActions.swift +++ b/Sources/HostSettingsActions.swift @@ -18,6 +18,7 @@ private let hostSettingsLogger = Logger(subsystem: "com.cmuxterm.app", category: @MainActor final class HostSettingsActions: SettingsHostActions { private let configFileURL: URL + private let automationConfigStore: AutomationConfigStore private let computerUseRuntimeService: ComputerUseRuntimeService private var runComputerUseOnboardingAction: @MainActor (ComputerUseOnboardingWindowController.StartingPoint) -> Void = { _ in } @@ -53,9 +54,11 @@ final class HostSettingsActions: SettingsHostActions { init( configFileURL: URL, - computerUseRuntimeService: ComputerUseRuntimeService + computerUseRuntimeService: ComputerUseRuntimeService, + automationConfigStore: AutomationConfigStore = AutomationConfigStore() ) { self.configFileURL = configFileURL + self.automationConfigStore = automationConfigStore self.computerUseRuntimeService = computerUseRuntimeService startObservingAppIconMode() } @@ -178,6 +181,51 @@ final class HostSettingsActions: SettingsHostActions { PreferredEditorService(defaults: .standard).open(configFileURL) } + /// Reads the existing automation configuration off-main and summarizes it for Settings. + func automationRulesStatus() async -> AutomationRulesStatus { + let fileURL = automationConfigStore.fileURL + let configExists = FileManager.default.fileExists(atPath: fileURL.path) + do { + let configuration = try await automationConfigStore.loadOffMain() + let enabledCount = configuration.rules.reduce(into: 0) { count, rule in + if rule.enabled { count += 1 } + } + return AutomationRulesStatus( + configPath: fileURL.path, + ruleCount: configuration.rules.count, + enabledCount: enabledCount, + configExists: configExists + ) + } catch { + hostSettingsLogger.error("Failed to load automation rules: \(String(describing: error), privacy: .private)") + return AutomationRulesStatus( + configPath: fileURL.path, + ruleCount: 0, + enabledCount: 0, + configExists: configExists, + hasError: true + ) + } + } + + /// Materializes the existing empty v1 configuration when needed, then opens it in the preferred editor. + func openAutomationRulesInExternalEditor() { + let fileURL = automationConfigStore.fileURL + if !FileManager.default.fileExists(atPath: fileURL.path) { + try? automationConfigStore.save(AutomationConfiguration()) + } + PreferredEditorService(defaults: .standard).open(fileURL) + } + + /// Routes a reload request to the already-attached automation engine. + @discardableResult + func reloadAutomationRules() -> Bool { + if case .ok = TerminalController.shared.v2AutomationReload() { + return true + } + return false + } + func sendFeedback() { guard let url = URL(string: "https://github.com/manaflow-ai/cmux/issues/new") else { return } NSWorkspace.shared.open(url) diff --git a/cmuxTests/HostSettingsShortcutNotificationTests.swift b/cmuxTests/HostSettingsShortcutNotificationTests.swift index 0c5714bbd836..050d1b71517a 100644 --- a/cmuxTests/HostSettingsShortcutNotificationTests.swift +++ b/cmuxTests/HostSettingsShortcutNotificationTests.swift @@ -29,6 +29,73 @@ struct HostSettingsShortcutNotificationTests { ) } + /// Verifies Settings receives the enabled/disabled split from the authoritative config store. + @Test + func automationRulesStatusReportsEnabledAndDisabledCounts() async throws { + let directoryURL = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-host-automation-status-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let store = AutomationConfigStore(fileURL: directoryURL.appendingPathComponent("automations.json")) + try store.save(AutomationConfiguration(rules: [ + AutomationRule( + id: "enabled-one", + when: AutomationWhen(event: "workspace.created"), + actions: [AutomationAction(action: "notify")] + ), + AutomationRule( + id: "disabled", + when: AutomationWhen(event: "agent.completed"), + actions: [AutomationAction(action: "notify")], + enabled: false + ), + AutomationRule( + id: "enabled-two", + when: AutomationWhen(category: "agent"), + actions: [AutomationAction(action: "notify")] + ), + ])) + + let host = HostSettingsActions( + configFileURL: directoryURL.appendingPathComponent("cmux.json"), + computerUseRuntimeService: ComputerUseRuntimeService(), + automationConfigStore: store + ) + let status = await host.automationRulesStatus() + + #expect(status.configPath == store.fileURL.path) + #expect(status.configExists) + #expect(status.ruleCount == 3) + #expect(status.enabledCount == 2) + #expect(status.disabledCount == 1) + #expect(!status.hasError) + } + + /// Verifies malformed configuration is reduced to a product-safe error flag. + @Test + func automationRulesStatusSurfacesConfigurationErrors() async throws { + let directoryURL = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-host-automation-error-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let fileURL = directoryURL.appendingPathComponent("automations.json") + try Data(#"{\"version\":1,\"rules\":["#.utf8).write(to: fileURL) + let store = AutomationConfigStore(fileURL: fileURL) + let host = HostSettingsActions( + configFileURL: directoryURL.appendingPathComponent("cmux.json"), + computerUseRuntimeService: ComputerUseRuntimeService(), + automationConfigStore: store + ) + let status = await host.automationRulesStatus() + + #expect(status.configExists) + #expect(status.ruleCount == 0) + #expect(status.enabledCount == 0) + #expect(status.hasError) + } + private func withSettingsFile( initialContents: String, updatedContents: String, diff --git a/cmuxUITests/SettingsAutomationBehaviorUITests.swift b/cmuxUITests/SettingsAutomationBehaviorUITests.swift index 0798fa21cdb5..678fa806f941 100644 --- a/cmuxUITests/SettingsAutomationBehaviorUITests.swift +++ b/cmuxUITests/SettingsAutomationBehaviorUITests.swift @@ -63,6 +63,9 @@ final class SettingsAutomationBehaviorUITests: SettingsUITestCase { static let openAccessDialogCancel = "Cancel" static let openAccessWarningPrefix = "Warning: Full open access makes the control socket" + static let rulesTitle = "Automation Rules" + static let rulesReloadRequested = "Reload requested." + // Claude Code Integration subtitles. static let claudeOn = "Sidebar shows Claude session status and notifications." static let claudeOff = "Claude Code runs without cmux integration." @@ -137,6 +140,48 @@ final class SettingsAutomationBehaviorUITests: SettingsUITestCase { return false } + // MARK: - TIER 1: Existing automation rules are discoverable and reloadable + + /// Verifies the native card is discoverable and routes reload to the existing engine. + func testAutomationRulesCardExposesNativeActionsAndReloadsEngine() { + let app = makeLaunchedApp() + let window = openAutomation(app) + defer { closeSettings(app, window) } + + XCTAssertTrue( + poll(timeout: 4.0) { window.staticTexts[L.rulesTitle].exists }, + "Automation Rules card should be visible in Settings > Automation" + ) + + let editButton = requireElement( + candidates: [ + window.buttons["SettingsAutomationRulesEditButton"], + window.descendants(matching: .any)["SettingsAutomationRulesEditButton"], + ], + timeout: 4.0, + description: "automation rules edit button" + ) + XCTAssertTrue(editButton.exists, "Automation Rules should expose the existing config file") + + let reloadButton = requireElement( + candidates: [ + window.buttons["SettingsAutomationRulesReloadButton"], + window.descendants(matching: .any)["SettingsAutomationRulesReloadButton"], + ], + timeout: 4.0, + description: "automation rules reload button" + ) + if !reloadButton.isHittable, window.scrollViews.firstMatch.exists { + window.scrollViews.firstMatch.swipeUp() + } + reloadButton.click() + + XCTAssertTrue( + poll(timeout: 4.0) { window.staticTexts[L.rulesReloadRequested].exists }, + "Reload should route to the running automation engine" + ) + } + // MARK: - TIER 1: Socket Control Mode reveals/hides the password subrow /// Default mode is `cmux processes only`, so the Socket Password row must