From e6623124c979d232098847f2462c842fe4ccd186 Mon Sep 17 00:00:00 2001 From: Aleksandr Pavlov Date: Sat, 11 Apr 2026 19:00:54 +0300 Subject: [PATCH 1/5] Add generic provider abstraction for usage accounts --- GhosttyTabs.xcodeproj/project.pbxproj | 52 + Resources/Localizable.xcstrings | 3689 +++++++++++++++++ Sources/AppDelegate.swift | 5 + Sources/ContentView.swift | 20 +- Sources/Providers/ProviderAccount.swift | 66 + Sources/Providers/ProviderAccountStore.swift | 380 ++ .../ProviderAccountsController.swift | 373 ++ Sources/Providers/ProviderHTTP.swift | 96 + .../Providers/ProviderISO8601DateParser.swift | 22 + Sources/Providers/ProviderRegistry.swift | 21 + .../ProviderUsageColorSettings.swift | 206 + Sources/Providers/StatuspageIOFetcher.swift | 80 + Sources/Providers/UsageProvider.swift | 60 + .../Sidebar/ProviderAccountEditorSheet.swift | 210 + .../Sidebar/ProviderAccountsFooterPanel.swift | 645 +++ Sources/Sidebar/ProviderAccountsPopover.swift | 371 ++ .../ProviderAccountsSettingsSection.swift | 386 ++ Sources/cmuxApp.swift | 102 +- docs/providers.md | 70 + 19 files changed, 6844 insertions(+), 10 deletions(-) create mode 100644 Sources/Providers/ProviderAccount.swift create mode 100644 Sources/Providers/ProviderAccountStore.swift create mode 100644 Sources/Providers/ProviderAccountsController.swift create mode 100644 Sources/Providers/ProviderHTTP.swift create mode 100644 Sources/Providers/ProviderISO8601DateParser.swift create mode 100644 Sources/Providers/ProviderRegistry.swift create mode 100644 Sources/Providers/ProviderUsageColorSettings.swift create mode 100644 Sources/Providers/StatuspageIOFetcher.swift create mode 100644 Sources/Providers/UsageProvider.swift create mode 100644 Sources/Sidebar/ProviderAccountEditorSheet.swift create mode 100644 Sources/Sidebar/ProviderAccountsFooterPanel.swift create mode 100644 Sources/Sidebar/ProviderAccountsPopover.swift create mode 100644 Sources/Sidebar/ProviderAccountsSettingsSection.swift create mode 100644 docs/providers.md diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 2aae9f1b8..7d7638f09 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -28,6 +28,19 @@ A5001500 /* CmuxWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001510 /* CmuxWebView.swift */; }; A5001501 /* UITestRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001511 /* UITestRecorder.swift */; }; A5001226 /* SocketControlSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001225 /* SocketControlSettings.swift */; }; + CA100005 /* StatuspageIOFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA100004 /* StatuspageIOFetcher.swift */; }; + CA100009 /* ProviderAccountsFooterPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA100008 /* ProviderAccountsFooterPanel.swift */; }; + CA100091 /* ProviderAccountsSettingsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA100090 /* ProviderAccountsSettingsSection.swift */; }; + CA100095 /* ProviderAccountsPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA100094 /* ProviderAccountsPopover.swift */; }; + CA10000B /* ProviderUsageColorSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA10000A /* ProviderUsageColorSettings.swift */; }; + CA10000D /* ProviderAccountEditorSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA10000C /* ProviderAccountEditorSheet.swift */; }; + CA100011 /* UsageProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA100010 /* UsageProvider.swift */; }; + CA100013 /* ProviderISO8601DateParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA100012 /* ProviderISO8601DateParser.swift */; }; + CA100093 /* ProviderHTTP.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA100092 /* ProviderHTTP.swift */; }; + CA100015 /* ProviderAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA100014 /* ProviderAccount.swift */; }; + CA100017 /* ProviderAccountStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA100016 /* ProviderAccountStore.swift */; }; + CA100019 /* ProviderAccountsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA100018 /* ProviderAccountsController.swift */; }; + CA10001B /* ProviderRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA10001A /* ProviderRegistry.swift */; }; A5001601 /* SentryHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001600 /* SentryHelper.swift */; }; A5001621 /* AppleScriptSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001620 /* AppleScriptSupport.swift */; }; D1320AA0D1320AA0D1320AA1 /* AppIconDockTilePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1320AA0D1320AA0D1320AA4 /* AppIconDockTilePlugin.swift */; }; @@ -239,6 +252,19 @@ A5001511 /* UITestRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITestRecorder.swift; sourceTree = ""; }; A5001520 /* PostHogAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogAnalytics.swift; sourceTree = ""; }; A5001225 /* SocketControlSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketControlSettings.swift; sourceTree = ""; }; + CA100004 /* StatuspageIOFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Providers/StatuspageIOFetcher.swift; sourceTree = ""; }; + CA100008 /* ProviderAccountsFooterPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/ProviderAccountsFooterPanel.swift; sourceTree = ""; }; + CA100090 /* ProviderAccountsSettingsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/ProviderAccountsSettingsSection.swift; sourceTree = ""; }; + CA100094 /* ProviderAccountsPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/ProviderAccountsPopover.swift; sourceTree = ""; }; + CA10000A /* ProviderUsageColorSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Providers/ProviderUsageColorSettings.swift; sourceTree = ""; }; + CA10000C /* ProviderAccountEditorSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/ProviderAccountEditorSheet.swift; sourceTree = ""; }; + CA100010 /* UsageProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Providers/UsageProvider.swift; sourceTree = ""; }; + CA100012 /* ProviderISO8601DateParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Providers/ProviderISO8601DateParser.swift; sourceTree = ""; }; + CA100092 /* ProviderHTTP.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Providers/ProviderHTTP.swift; sourceTree = ""; }; + CA100014 /* ProviderAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Providers/ProviderAccount.swift; sourceTree = ""; }; + CA100016 /* ProviderAccountStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Providers/ProviderAccountStore.swift; sourceTree = ""; }; + CA100018 /* ProviderAccountsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Providers/ProviderAccountsController.swift; sourceTree = ""; }; + CA10001A /* ProviderRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Providers/ProviderRegistry.swift; sourceTree = ""; }; A5001410 /* Panel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/Panel.swift; sourceTree = ""; }; A5001411 /* TerminalPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/TerminalPanel.swift; sourceTree = ""; }; A5001412 /* BrowserPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPanel.swift; sourceTree = ""; }; @@ -486,6 +512,19 @@ A5001544 /* TerminalImageTransfer.swift */, A5001545 /* TerminalSSHSessionDetector.swift */, A5001225 /* SocketControlSettings.swift */, + CA100004 /* StatuspageIOFetcher.swift */, + CA100008 /* ProviderAccountsFooterPanel.swift */, + CA100090 /* ProviderAccountsSettingsSection.swift */, + CA100094 /* ProviderAccountsPopover.swift */, + CA10000A /* ProviderUsageColorSettings.swift */, + CA10000C /* ProviderAccountEditorSheet.swift */, + CA100010 /* UsageProvider.swift */, + CA100012 /* ProviderISO8601DateParser.swift */, + CA100092 /* ProviderHTTP.swift */, + CA100014 /* ProviderAccount.swift */, + CA100016 /* ProviderAccountStore.swift */, + CA100018 /* ProviderAccountsController.swift */, + CA10001A /* ProviderRegistry.swift */, A5001600 /* SentryHelper.swift */, A5001620 /* AppleScriptSupport.swift */, D1320AA0D1320AA0D1320AA4 /* AppIconDockTilePlugin.swift */, @@ -816,6 +855,19 @@ A5001542 /* TerminalImageTransfer.swift in Sources */, A5001543 /* TerminalSSHSessionDetector.swift in Sources */, A5001226 /* SocketControlSettings.swift in Sources */, + CA100005 /* StatuspageIOFetcher.swift in Sources */, + CA100009 /* ProviderAccountsFooterPanel.swift in Sources */, + CA100091 /* ProviderAccountsSettingsSection.swift in Sources */, + CA100095 /* ProviderAccountsPopover.swift in Sources */, + CA10000B /* ProviderUsageColorSettings.swift in Sources */, + CA10000D /* ProviderAccountEditorSheet.swift in Sources */, + CA100011 /* UsageProvider.swift in Sources */, + CA100013 /* ProviderISO8601DateParser.swift in Sources */, + CA100093 /* ProviderHTTP.swift in Sources */, + CA100015 /* ProviderAccount.swift in Sources */, + CA100017 /* ProviderAccountStore.swift in Sources */, + CA100019 /* ProviderAccountsController.swift in Sources */, + CA10001B /* ProviderRegistry.swift in Sources */, A5001601 /* SentryHelper.swift in Sources */, A5001621 /* AppleScriptSupport.swift in Sources */, A5001093 /* AppDelegate.swift in Sources */, diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 7649789be..8f17697b7 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -87714,6 +87714,3695 @@ } } } + }, + "providers.accounts.error.decoding": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed to decode account credentials from Keychain." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Keychainからアカウント資格情報をデコードできませんでした。" + } + } + } + }, + "providers.accounts.error.keychainStatus": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Keychain error (OSStatus %lld). Check macOS Keychain Access permissions." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Keychainエラー (OSStatus %lld)。macOSのKeychain Access権限を確認してください。" + } + } + } + }, + "providers.accounts.error.notFound": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Account not found." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アカウントが見つかりません。" + } + } + } + }, + "providers.accounts.footer.loading": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "loading…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "読み込み中…" + } + } + } + }, + "providers.accounts.header.collapse": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Collapse %@ accounts" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@アカウントを折りたたむ" + } + } + } + }, + "providers.accounts.header.expand": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Expand %@ accounts" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@アカウントを展開" + } + } + } + }, + "providers.accounts.popover.noData": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "—" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "—" + } + } + } + }, + "providers.accounts.popover.resets": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Resets" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リセット" + } + } + } + }, + "providers.accounts.popover.today": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Today" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "今日" + } + } + } + }, + "providers.accounts.popover.tomorrow": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tomorrow" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "明日" + } + } + } + }, + "providers.accounts.status.openPage": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open status page for %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@のステータスページを開く" + } + } + } + }, + "providers.accounts.editor.cancel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancel" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キャンセル" + } + } + } + }, + "providers.accounts.editor.name": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Display name" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "表示名" + } + } + } + }, + "providers.accounts.editor.save": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Save" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "保存" + } + } + } + }, + "providers.accounts.error.loadSecret": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Could not load saved credentials. Re-enter them to save changes." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "保存された資格情報を読み込めませんでした。変更を保存するには再入力してください。" + } + } + } + }, + "providers.accounts.error.unknownProvider": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unknown provider: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "不明なプロバイダー: %@" + } + } + } + }, + "providers.accounts.help.link": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Setup instructions" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "セットアップ手順" + } + } + } + }, + "providers.accounts.section.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "مراقبة استخدام الذكاء الاصطناعي" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Praćenje korištenja AI-ja" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "AI-brugsovervågning" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "AI-Nutzungsüberwachung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "AI Usage Monitoring" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Monitoreo de uso de IA" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Suivi d'utilisation IA" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Monitoraggio utilizzo IA" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "AI使用量モニタリング" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "AI 사용량 모니터링" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "AI-bruksovervåking" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Monitorowanie użycia AI" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Monitoramento de uso de IA" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Мониторинг использования ИИ" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "การตรวจสอบการใช้งาน AI" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "AI Kullanım İzleme" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Моніторинг використання ШІ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "AI 使用量监控" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "AI 使用量監控" + } + } + } + }, + "providers.accounts.add.button": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Add profile…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プロファイルを追加…" + } + } + } + }, + "providers.accounts.add.menu.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Add %@ profile…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@プロファイルを追加…" + } + } + } + }, + "providers.accounts.editor.title.add": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Add %@ account" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@アカウントを追加" + } + } + } + }, + "providers.accounts.editor.title.edit": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Edit %@ account" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@アカウントを編集" + } + } + } + }, + "providers.accounts.footer.accessibility": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "استخدام حساب Claude" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Korištenje Claude računa" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Claude-kontoforbrug" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Claude-Kontonutzung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Claude account usage" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Uso de cuenta de Claude" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Utilisation du compte Claude" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Utilizzo account Claude" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Claudeアカウント使用状況" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Claude 계정 사용량" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Claude-kontobruk" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Użycie konta Claude" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Uso da conta Claude" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Использование аккаунта Claude" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "การใช้งานบัญชี Claude" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Claude hesap kullanımı" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Використання облікового запису Claude" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Claude 账户使用量" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Claude 帳戶使用量" + } + } + } + }, + "providers.accounts.footer.session": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "جلسة" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ses." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Ses." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sitz." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Sess" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ses." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ses." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Ses." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "セッション" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "세션" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Økt" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Ses." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Ses." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сеанс" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เซสชัน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Otrm." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Сеанс" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "会话" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "工作階段" + } + } + } + }, + "providers.accounts.footer.week": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "أسبوع" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Sedmica" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Uge" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Woche" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Week" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Semana" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Sem." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Sett." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "週" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "주간" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Uke" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Tydzień" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Semana" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Неделя" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "สัปดาห์" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Hafta" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Тиждень" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "周" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "週" + } + } + } + }, + "providers.accounts.status.allOk": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "جميع الأنظمة تعمل" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Svi sistemi rade" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Alle systemer fungerer" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Alle Systeme funktionieren" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "All systems operational" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Todos los sistemas operativos" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Tous les systèmes opérationnels" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Tutti i sistemi operativi" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "全システム正常稼働中" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "모든 시스템 정상 작동 중" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Alle systemer fungerer" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wszystkie systemy działają" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Todos os sistemas operacionais" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Все системы работают" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ระบบทั้งหมดทำงานปกติ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tüm sistemler çalışıyor" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Усі системи працюють" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "所有系统运行正常" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "所有系統運作正常" + } + } + } + }, + "providers.accounts.status.fetchFailed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذر التحقق من الحالة" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Nije moguće provjeriti status" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Kunne ikke kontrollere status" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Status konnte nicht überprüft werden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Could not check status" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo verificar el estado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible de vérifier l'état" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Impossibile verificare lo stato" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ステータスを確認できませんでした" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "상태를 확인할 수 없습니다" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Kunne ikke sjekke status" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nie udało się sprawdzić statusu" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Não foi possível verificar o status" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось проверить статус" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไม่สามารถตรวจสอบสถานะ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Durum kontrol edilemedi" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Не вдалося перевірити статус" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法检查状态" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法檢查狀態" + } + } + } + }, + "providers.accounts.status.staleWarning": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "قد تكون الحالة قديمة" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Status je možda zastarjeo" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Status kan være forældet" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Status ist möglicherweise veraltet" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Status may be outdated" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El estado puede estar desactualizado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "L'état peut être obsolète" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Lo stato potrebbe essere obsoleto" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ステータスが古い可能性があります" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "상태가 오래되었을 수 있습니다" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Status kan være utdatert" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Status może być nieaktualny" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "O status pode estar desatualizado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Статус может быть устаревшим" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "สถานะอาจล้าสมัย" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Durum güncel olmayabilir" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Статус може бути застарілим" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "状态可能已过时" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "狀態可能已過時" + } + } + } + }, + "providers.accounts.status.loading": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "جارٍ التحقق من الحالة…" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Provjera statusa…" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Kontrollerer status…" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Status wird überprüft…" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Checking status…" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Comprobando estado…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Vérification de l'état…" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Verifica dello stato…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ステータスを確認中…" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "상태 확인 중…" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Sjekker status…" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Sprawdzanie statusu…" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Verificando status…" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Проверка статуса…" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "กำลังตรวจสอบสถานะ…" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Durum kontrol ediliyor…" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Перевірка статусу…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在检查状态…" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在檢查狀態…" + } + } + } + }, + "providers.accounts.refresh.now": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تحديث الآن" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Osvježi sada" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Opdater nu" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Jetzt aktualisieren" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Refresh now" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Actualizar ahora" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Actualiser maintenant" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Aggiorna ora" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "今すぐ更新" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "지금 새로고침" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Oppdater nå" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Odśwież teraz" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Atualizar agora" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Обновить сейчас" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "รีเฟรชตอนนี้" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Şimdi yenile" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Оновити зараз" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "立即刷新" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "立即重新整理" + } + } + } + }, + "providers.accounts.manage": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إدارة الحسابات…" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Upravljanje računima…" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Administrer konti…" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Konten verwalten…" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Manage accounts…" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Gestionar cuentas…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Gérer les comptes…" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Gestisci account…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アカウントを管理…" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "계정 관리…" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Administrer kontoer…" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zarządzaj kontami…" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Gerenciar contas…" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Управление аккаунтами…" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "จัดการบัญชี…" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Hesapları yönet…" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Керувати обліковими записами…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "管理账户…" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "管理帳戶…" + } + } + } + }, + "providers.accounts.popover.fetchedAt": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "آخر تحديث" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ažurirano" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Opdateret" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktualisiert" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Updated" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Actualizado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Mis à jour" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Aggiornato" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "更新" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "업데이트됨" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Oppdatert" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zaktualizowano" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Atualizado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Обновлено" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "อัปเดตแล้ว" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Güncellendi" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Оновлено" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已更新" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已更新" + } + } + } + }, + "provider.status.operational": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يعمل" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Operativno" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Operationel" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Betriebsbereit" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Operational" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Operativo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Opérationnel" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Operativo" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "正常" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "정상" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Operativ" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Działa" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Operacional" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Работает" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ปกติ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çalışıyor" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Працює" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正常" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正常" + } + } + } + }, + "provider.status.minor": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "مشكلة بسيطة" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Manji problem" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Mindre problem" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kleines Problem" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Minor issue" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Problema menor" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Problème mineur" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Problema minore" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "軽微な問題" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "경미한 문제" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Mindre problem" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Drobny problem" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Problema menor" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Незначительная проблема" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ปัญหาเล็กน้อย" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Küçük sorun" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Незначна проблема" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "轻微问题" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "輕微問題" + } + } + } + }, + "provider.status.degraded": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "متدهور" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Degradirano" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Forringet" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Beeinträchtigt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Degraded" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Degradado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Dégradé" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Degradato" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "低下" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "저하됨" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Forringet" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pogorszone" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Degradado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ухудшено" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ประสิทธิภาพลดลง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Düşük performans" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Погіршено" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "性能下降" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "效能下降" + } + } + } + }, + "provider.status.critical": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "حرج" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Kritično" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Kritisk" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kritisch" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Critical" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Crítico" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Critique" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Critico" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "重大" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "심각" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Kritisk" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Krytyczny" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Crítico" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Критично" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "วิกฤต" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kritik" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Критично" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "严重" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "嚴重" + } + } + } + }, + "provider.status.unknown": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "غير معروف" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Nepoznato" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Ukendt" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Unbekannt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Unknown" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Desconocido" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Inconnu" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Sconosciuto" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "不明" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "알 수 없음" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Ukjent" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nieznany" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Desconhecido" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Неизвестно" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไม่ทราบ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bilinmiyor" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Невідомо" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未知" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未知" + } + } + } + }, + "providers.accounts.usage.notStarted": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لم تبدأ" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "nije započeto" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "ikke startet" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "nicht gestartet" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "not started" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "no iniciado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "non démarré" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "non avviato" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "未開始" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "시작되지 않음" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "ikke startet" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "nie rozpoczęto" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "não iniciado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "не начато" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ยังไม่เริ่ม" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "başlamadı" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "не розпочато" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未开始" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未開始" + } + } + } + }, + "providers.accounts.usage.sessionNotStarted": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لم تبدأ الجلسة" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "sesija nije započeta" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "session ikke startet" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sitzung nicht gestartet" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Session not started" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "sesión no iniciada" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "session non démarrée" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "sessione non avviata" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "セッション未開始" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "세션이 시작되지 않음" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "økt ikke startet" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "sesja nie rozpoczęta" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "sessão não iniciada" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "сеанс не начат" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เซสชันยังไม่เริ่ม" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "oturum başlamadı" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "сеанс не розпочато" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "会话未开始" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "工作階段未開始" + } + } + } + }, + "providers.accounts.usage.resets": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يُعاد التعيين" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "resetuje se" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "nulstilles" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Reset" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "resets" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "se restablece" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "réinitialise" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "si reimposta" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リセット" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "재설정" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "nullstilles" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "resetuje się" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "redefine" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "сброс" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "รีเซ็ต" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "sıfırlanır" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "скидається" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重置" + } + } + } + }, + "providers.accounts.empty": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No profiles yet" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プロファイルなし" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لا توجد ملفات تعريف بعد" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Još nema profila" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Ingen profiler endnu" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Noch keine Profile" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Sin perfiles aún" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucun profil pour le moment" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Nessun profilo ancora" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "프로필 없음" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Ingen profiler ennå" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Brak profili" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Nenhum perfil ainda" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Профилей пока нет" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ยังไม่มีโปรไฟล์" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Henüz profil yok" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Профілів ще немає" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "暂无配置文件" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "尚無設定檔" + } + } + } + }, + "providers.accounts.edit.button": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Edit" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "編集" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعديل" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Uredi" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Rediger" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bearbeiten" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Editar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Modifier" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Modifica" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "편집" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Rediger" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Edytuj" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Editar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Изменить" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แก้ไข" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Düzenle" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Редагувати" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "编辑" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "編輯" + } + } + } + }, + "providers.accounts.remove.button": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "削除" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إزالة" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ukloni" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Fjern" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Entfernen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Eliminar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Supprimer" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Rimuovi" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "제거" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Fjern" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Usuń" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Remover" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Удалить" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ลบ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kaldır" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Видалити" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "移除" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "移除" + } + } + } + }, + "providers.accounts.remove.confirm.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove this account?" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このアカウントを削除しますか?" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "هل تريد إزالة هذا الحساب؟" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ukloniti ovaj račun?" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Fjern denne konto?" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Dieses Konto entfernen?" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "¿Eliminar esta cuenta?" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Supprimer ce compte ?" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Rimuovere questo account?" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 계정을 제거하시겠습니까?" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Fjern denne kontoen?" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Usunąć to konto?" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Remover esta conta?" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Удалить этот аккаунт?" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ลบบัญชีนี้หรือไม่?" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bu hesap kaldırılsın mı?" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Видалити цей обліковий запис?" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "移除此账户?" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "移除此帳戶?" + } + } + } + }, + "providers.accounts.remove.confirm.action": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "削除" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إزالة" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ukloni" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Fjern" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Entfernen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Eliminar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Supprimer" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Rimuovi" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "제거" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Fjern" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Usuń" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Remover" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Удалить" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ลบ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kaldır" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Видалити" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "移除" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "移除" + } + } + } + }, + "providers.accounts.remove.confirm.body": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The saved credentials will be deleted from Keychain." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "保存された認証情報がキーチェーンから削除されます。" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "سيتم حذف بيانات الاعتماد المحفوظة من سلسلة المفاتيح." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Spremljeni podaci za prijavu bit će izbrisani iz Keychain-a." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "De gemte legitimationsoplysninger slettes fra nøglering." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die gespeicherten Anmeldedaten werden aus dem Schlüsselbund gelöscht." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Las credenciales guardadas se eliminarán del llavero." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Les identifiants enregistrés seront supprimés du trousseau." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Le credenziali salvate verranno eliminate dal Portachiavi." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "저장된 자격 증명이 키체인에서 삭제됩니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "De lagrede legitimasjonene slettes fra nøkkelringen." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zapisane dane uwierzytelniające zostaną usunięte z pęku kluczy." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "As credenciais salvas serão excluídas do Chaveiro." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сохранённые учётные данные будут удалены из Связки ключей." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ข้อมูลรับรองที่บันทึกไว้จะถูกลบออกจาก Keychain" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kaydedilen kimlik bilgileri Anahtarlık'tan silinecek." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Збережені облікові дані буде видалено з В'язки ключів." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已保存的凭据将从钥匙串中删除。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已儲存的憑證將從鑰匙圈中刪除。" + } + } + } + }, + "providers.accounts.remove.cancel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancel" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キャンセル" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إلغاء" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Otkaži" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Annuller" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Abbrechen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cancelar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Annuler" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Annulla" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "취소" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Avbryt" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Anuluj" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Cancelar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отмена" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ยกเลิก" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "İptal" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Скасувати" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "取消" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "取消" + } + } + } + }, + "providers.accounts.remove.error.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Could not remove account" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アカウントを削除できませんでした" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذّر إزالة الحساب" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Nije moguće ukloniti račun" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Kunne ikke fjerne kontoen" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Konto konnte nicht entfernt werden" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo eliminar la cuenta" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible de supprimer le compte" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Impossibile rimuovere l'account" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "계정을 제거할 수 없습니다" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Kunne ikke fjerne kontoen" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nie można usunąć konta" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Não foi possível remover a conta" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось удалить аккаунт" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไม่สามารถลบบัญชีได้" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Hesap kaldırılamadı" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Не вдалося видалити обліковий запис" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法移除账户" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法移除帳戶" + } + } + } + }, + "providers.accounts.remove.error.ok": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "موافق" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Aceptar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "확인" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "ОК" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ตกลง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tamam" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "好" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "好" + } + } + } } } } diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 7e346a71f..8c43d0189 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2646,6 +2646,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent installShortcutMonitor() installShortcutDefaultsObserver() SystemWideHotkeyController.shared.start() + if !isRunningUnderXCTest { + _ = ProviderAccountStore.shared + ProviderAccountsController.shared.start() + } NSApp.servicesProvider = self #if DEBUG UpdateTestSupport.applyIfNeeded(to: updateController.viewModel) @@ -3006,6 +3010,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if TelemetrySettings.enabledForCurrentLaunch { PostHogAnalytics.shared.flush() } + ProviderAccountsController.shared.stop() notificationStore?.clearAll() enableSuddenTerminationIfNeeded() } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index d90e52592..5d1b5a027 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -11239,12 +11239,22 @@ private struct SidebarFooter: View { var body: some View { #if DEBUG - SidebarDevFooter(updateViewModel: updateViewModel, fileExplorerState: fileExplorerState, onSendFeedback: onSendFeedback) + VStack(alignment: .leading, spacing: 4) { + ProviderAccountsFooterPanel() + .padding(.leading, 6) + .padding(.trailing, 10) + SidebarDevFooter(updateViewModel: updateViewModel, fileExplorerState: fileExplorerState, onSendFeedback: onSendFeedback) + } #else - SidebarFooterButtons(updateViewModel: updateViewModel, fileExplorerState: fileExplorerState, onSendFeedback: onSendFeedback) - .padding(.leading, 6) - .padding(.trailing, 10) - .padding(.bottom, 6) + VStack(alignment: .leading, spacing: 4) { + ProviderAccountsFooterPanel() + .padding(.leading, 6) + .padding(.trailing, 10) + SidebarFooterButtons(updateViewModel: updateViewModel, fileExplorerState: fileExplorerState, onSendFeedback: onSendFeedback) + .padding(.leading, 6) + .padding(.trailing, 10) + .padding(.bottom, 6) + } #endif } } diff --git a/Sources/Providers/ProviderAccount.swift b/Sources/Providers/ProviderAccount.swift new file mode 100644 index 000000000..19a22e949 --- /dev/null +++ b/Sources/Providers/ProviderAccount.swift @@ -0,0 +1,66 @@ +import Foundation + +// MARK: - Account + +struct ProviderAccount: Identifiable, Equatable, Codable { + let id: UUID + let providerId: String // "claude" | "codex" | ... + var displayName: String + /// The keychain service name captured at account creation. Persisted so + /// removal and secret lookups still target the correct keychain slot even + /// if the provider definition is later renamed or stops shipping in the + /// registry. `nil` for accounts written by earlier builds — those fall + /// back to the current `keychainServiceResolver` lookup. + var keychainService: String? +} + +// MARK: - Secret + +struct ProviderSecret: Codable, Sendable, CustomStringConvertible, CustomDebugStringConvertible, CustomReflectable { + let fields: [String: String] // dynamic credential payload + + /// The secret intentionally elides every field value so that + /// accidentally printing / logging / Sentry-breadcrumbing a + /// `ProviderSecret` (including via string interpolation, dictionary + /// dumps, or Xcode quick-look) never leaks a live session token. + /// The defense is cheap and catches a whole class of future mistakes. + var description: String { + let keys = fields.keys.sorted().joined(separator: ", ") + return "ProviderSecret(fields: [\(keys)] = )" + } + + var debugDescription: String { description } + + /// Reflection APIs (`dump`, Xcode quick-look, swift-playgrounds) walk the + /// stored property tree independently of `description`. Overriding the + /// mirror keeps raw credential strings from appearing in debugger dumps + /// even when a caller bypasses `CustomStringConvertible`. + var customMirror: Mirror { + Mirror( + self, + children: [ + "fields": fields.keys.sorted().map { "\($0): " } + ], + displayStyle: .struct + ) + } +} + +// MARK: - Store Errors + +enum ProviderAccountStoreError: Error, LocalizedError { + case keychain(OSStatus) + case decoding + case notFound + + var errorDescription: String? { + switch self { + case .keychain(let status): + return String(localized: "providers.accounts.error.keychainStatus", defaultValue: "Keychain error (OSStatus \(status)). Check macOS Keychain Access permissions.") + case .decoding: + return String(localized: "providers.accounts.error.decoding", defaultValue: "Failed to decode account credentials from Keychain.") + case .notFound: + return String(localized: "providers.accounts.error.notFound", defaultValue: "Account not found.") + } + } +} diff --git a/Sources/Providers/ProviderAccountStore.swift b/Sources/Providers/ProviderAccountStore.swift new file mode 100644 index 000000000..9444b90aa --- /dev/null +++ b/Sources/Providers/ProviderAccountStore.swift @@ -0,0 +1,380 @@ +import Foundation +import Security + +@MainActor +final class ProviderAccountStore: ObservableObject { + static let shared = ProviderAccountStore() + + @Published private(set) var accounts: [ProviderAccount] = [] + + private static let defaultIndexKey = "cmux.providers.accounts.index" + + private let userDefaults: UserDefaults + private let indexKey: String + + /// Resolves the keychain service name for a given providerId via ProviderRegistry. + /// Falls back to "com.cmuxterm.app.-accounts" if the provider is not registered. + var keychainServiceResolver: (String) -> String = { providerId in + ProviderRegistry.provider(id: providerId)?.keychainService + ?? "com.cmuxterm.app.\(providerId)-accounts" + } + + private init() { + self.userDefaults = .standard + self.indexKey = Self.defaultIndexKey + self.accounts = loadIndex() + Task { await pruneOrphanAccountsIfNeeded() } + } + + /// Initializer for tests only. Injects an isolated `UserDefaults` + /// suite and an index key so unit tests don't pollute (or read from) + /// the shared defaults domain used by `.shared`. Production code must + /// always go through `ProviderAccountStore.shared`; this module-internal + /// init exists purely so `cmuxTests/ProviderTests.swift` can round-trip + /// the store against a throwaway suite. + init(userDefaults: UserDefaults, indexKey: String, keychainServiceResolver: ((String) -> String)? = nil) { + self.userDefaults = userDefaults + self.indexKey = indexKey + if let keychainServiceResolver { + self.keychainServiceResolver = keychainServiceResolver + } + self.accounts = loadIndex() + Task { await pruneOrphanAccountsIfNeeded() } + } + + // MARK: - Public API + // + // Keychain-touching operations are `async` so the synchronous `SecItem*` + // calls run on a detached task instead of blocking the MainActor while + // the system Keychain resolves. `@Published accounts` mutations stay on + // main so SwiftUI observers don't need to hop actors. + + func reload() { + accounts = loadIndex() + Task { await pruneOrphanAccountsIfNeeded() } + } + + func add(providerId: String, displayName: String, secret: ProviderSecret) async throws { + guard ProviderRegistry.provider(id: providerId) != nil else { + throw ProviderAccountStoreError.notFound + } + let service = keychainServiceResolver(providerId) + let account = ProviderAccount( + id: UUID(), + providerId: providerId, + displayName: displayName, + keychainService: service + ) + try await ProviderAccountKeychain.save(secret: secret, for: account.id, service: service) + var current = accounts + current.append(account) + do { + try saveIndex(current) + } catch { + // Undo the keychain write if we can't persist the index — otherwise + // the credential would linger without a referencing account. + try? await ProviderAccountKeychain.delete(for: account.id, service: service) + throw error + } + accounts = current + } + + func update(id: UUID, displayName: String, secret: ProviderSecret) async throws { + guard let account = accounts.first(where: { $0.id == id }) else { + throw ProviderAccountStoreError.notFound + } + let service = serviceName(for: account) + // Keep the existing credentials on hand so a failure to persist the + // index can roll the keychain back; otherwise on-disk state could drift + // behind a successful keychain write. + let previousSecret = try? await ProviderAccountKeychain.load(for: id, service: service) + try await ProviderAccountKeychain.update(secret: secret, for: id, service: service) + // Actor reentrancy: another caller could have mutated `accounts` during + // the keychain awaits above, so look the row up fresh before touching + // it instead of trusting a snapshot taken beforehand. + var current = accounts + guard let index = current.firstIndex(where: { $0.id == id }) else { + if let previousSecret { + try? await ProviderAccountKeychain.update(secret: previousSecret, for: id, service: service) + } + throw ProviderAccountStoreError.notFound + } + current[index].displayName = displayName + do { + try saveIndex(current) + } catch { + if let previousSecret { + try? await ProviderAccountKeychain.update(secret: previousSecret, for: id, service: service) + } + throw error + } + accounts = current + } + + func remove(id: UUID) async throws { + guard let account = accounts.first(where: { $0.id == id }) else { + throw ProviderAccountStoreError.notFound + } + let service = serviceName(for: account) + // Delete the keychain secret first so a failure surfaces to the caller + // before any on-disk state changes. That way a "removed" account never + // lingers in the index with live credentials still on disk. + try await ProviderAccountKeychain.delete(for: id, service: service) + var current = accounts + current.removeAll { $0.id == id } + // Secret is already deleted from the keychain; if persisting the new + // index fails we still want the in-memory state to reflect reality so + // the UI doesn't show a phantom row pointing at a missing secret. + // The next `pruneOrphanAccountsIfNeeded` run will reconcile the + // stale on-disk entry. + do { + try saveIndex(current) + } catch { + NSLog("ProviderAccountStore: failed to persist index after removing \(id): \(error)") + } + accounts = current + } + + func secret(for id: UUID) async throws -> ProviderSecret { + guard let account = accounts.first(where: { $0.id == id }) else { + throw ProviderAccountStoreError.notFound + } + let service = serviceName(for: account) + return try await ProviderAccountKeychain.load(for: id, service: service) + } + + /// Resolves the keychain service to target for an account. A value stored + /// at account creation wins; when the field is missing (accounts written + /// by earlier builds) the registry resolver provides the fallback. + private func serviceName(for account: ProviderAccount) -> String { + account.keychainService ?? keychainServiceResolver(account.providerId) + } + + // MARK: - Index Persistence (UserDefaults) + + private func loadIndex() -> [ProviderAccount] { + guard let data = userDefaults.data(forKey: indexKey) else { + return [] + } + do { + return try JSONDecoder().decode([ProviderAccount].self, from: data) + } catch { + // Surface decode failures so corrupted index data doesn't silently + // hide stored accounts and their keychain credentials. + NSLog("ProviderAccountStore: failed to decode account index (\(data.count) bytes): \(error)") + return [] + } + } + + /// Drops accounts whose keychain secret is definitively missing (e.g. + /// after a crash between the keychain write and the index write). Runs + /// the `SecItem*` probes off the MainActor so UI doesn't hitch while the + /// system keychain resolves. Only `errSecItemNotFound` prunes — transient + /// failures like a locked keychain keep the entry so a later launch still + /// sees it. + private func pruneOrphanAccountsIfNeeded() async { + let snapshot = accounts + if snapshot.isEmpty { + return + } + var orphanIds: [UUID] = [] + for account in snapshot { + let service = serviceName(for: account) + let accountId = account.id + let status = await ProviderAccountKeychain.probePresenceAsync(for: accountId, service: service) + if status == errSecItemNotFound { + NSLog("ProviderAccountStore: dropping orphan account \(accountId) (keychain \(service) missing secret)") + orphanIds.append(accountId) + } + } + if orphanIds.isEmpty { + return + } + var current = accounts + current.removeAll { orphanIds.contains($0.id) } + try? saveIndex(current) + accounts = current + } + + private func saveIndex(_ accounts: [ProviderAccount]) throws { + let data = try JSONEncoder().encode(accounts) + userDefaults.set(data, forKey: indexKey) + } +} + +// MARK: - Keychain I/O (nonisolated) +// +// Kept separate from the MainActor-isolated store so that synchronous +// `SecItem*` calls run on a detached task without leaking their blocking +// behavior to the UI. Every call routes through `matchQuery` / +// `addAttributes` so accessibility + synchronizability attributes stay +// consistent: +// +// - kSecAttrAccessibleWhenUnlockedThisDeviceOnly — the credential is +// reachable while the device is unlocked and never migrates via +// Time Machine or iCloud keychain restore to a different machine. +// AI provider session tokens are inherently tied to the current +// machine's sign-in; they should not follow a user restore. +// - kSecAttrSynchronizable = kCFBooleanFalse — explicit opt-out of +// iCloud Keychain sync so a future default flip can't pick these +// items up without a code change. The attribute is part of both +// the match query and the write attributes so stored items and +// lookups stay perfectly symmetric. + +enum ProviderAccountKeychain { + /// Each entry point pre-checks cancellation and propagates it into the + /// detached task via `withTaskCancellationHandler`. The underlying + /// `SecItem*` calls are synchronous and not themselves interruptible, so + /// a keychain call already in flight still runs to completion — but an + /// upstream cancellation reaches the work before it starts and reaches + /// every subsequent call afterwards. + static func save(secret: ProviderSecret, for accountId: UUID, service: String) async throws { + try Task.checkCancellation() + try await runDetached { + try writeAdd(secret, for: accountId, service: service) + } + } + + static func update(secret: ProviderSecret, for accountId: UUID, service: String) async throws { + try Task.checkCancellation() + try await runDetached { + try writeUpdate(secret, for: accountId, service: service) + } + } + + static func load(for accountId: UUID, service: String) async throws -> ProviderSecret { + try Task.checkCancellation() + return try await runDetached { + try readLoad(for: accountId, service: service) + } + } + + static func delete(for accountId: UUID, service: String) async throws { + try Task.checkCancellation() + try await runDetached { + try writeDelete(for: accountId, service: service) + } + } + + /// Synchronous presence probe — returns the raw `OSStatus` without + /// decrypting or returning the payload. Used at load time to distinguish + /// "item missing" (`errSecItemNotFound`) from transient failures like a + /// locked keychain, so only true orphans are pruned. + static func probePresence(for accountId: UUID, service: String) -> OSStatus { + var query = matchQuery(service: service, accountId: accountId) + query[kSecMatchLimit] = kSecMatchLimitOne + query[kSecReturnData] = false + query[kSecReturnAttributes] = false + return SecItemCopyMatching(query as CFDictionary, nil) + } + + /// Off-main wrapper around `probePresence` so MainActor callers can run + /// the synchronous `SecItemCopyMatching` without blocking the UI. + static func probePresenceAsync(for accountId: UUID, service: String) async -> OSStatus { + let task = Task.detached(priority: .utility) { + probePresence(for: accountId, service: service) + } + return await task.value + } + + private static func runDetached(_ work: @Sendable @escaping () throws -> T) async throws -> T { + let task = Task.detached(priority: .userInitiated) { + try work() + } + return try await withTaskCancellationHandler { + try await task.value + } onCancel: { + task.cancel() + } + } + + // MARK: Synchronous internals + + private static func matchQuery(service: String, accountId: UUID) -> [CFString: Any] { + return [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: accountId.uuidString, + kSecAttrSynchronizable: kCFBooleanFalse as Any, + ] + } + + private static func addAttributes(payload: Data) -> [CFString: Any] { + return [ + kSecValueData: payload, + kSecAttrAccessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly, + kSecAttrSynchronizable: kCFBooleanFalse as Any, + ] + } + + private static func encodeSecret(_ secret: ProviderSecret) throws -> Data { + try JSONEncoder().encode(secret.fields) + } + + private static func decodeSecret(_ data: Data) throws -> ProviderSecret { + do { + let fields = try JSONDecoder().decode([String: String].self, from: data) + return ProviderSecret(fields: fields) + } catch { + throw ProviderAccountStoreError.decoding + } + } + + private static func writeAdd(_ secret: ProviderSecret, for accountId: UUID, service: String) throws { + let payload = try encodeSecret(secret) + var query = matchQuery(service: service, accountId: accountId) + query.merge(addAttributes(payload: payload)) { _, new in new } + let status = SecItemAdd(query as CFDictionary, nil) + if status == errSecDuplicateItem { + // Update in place instead of delete-then-add so there is no window + // where the credential is absent from the keychain. + let updateStatus = SecItemUpdate( + matchQuery(service: service, accountId: accountId) as CFDictionary, + addAttributes(payload: payload) as CFDictionary + ) + if updateStatus != errSecSuccess { + throw ProviderAccountStoreError.keychain(updateStatus) + } + } else if status != errSecSuccess { + throw ProviderAccountStoreError.keychain(status) + } + } + + private static func writeUpdate(_ secret: ProviderSecret, for accountId: UUID, service: String) throws { + let payload = try encodeSecret(secret) + let query = matchQuery(service: service, accountId: accountId) + let attributes = addAttributes(payload: payload) + // Strict update-only: if the item has vanished between the caller's + // `load(...)` and this write (e.g. a concurrent `remove(id:)` ran + // during the intervening await), surface `errSecItemNotFound` instead + // of silently re-creating an orphan credential that no index entry + // references. + let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + guard status == errSecSuccess else { + throw ProviderAccountStoreError.keychain(status) + } + } + + private static func readLoad(for accountId: UUID, service: String) throws -> ProviderSecret { + var query = matchQuery(service: service, accountId: accountId) + query[kSecReturnData] = true + query[kSecMatchLimit] = kSecMatchLimitOne + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { + throw ProviderAccountStoreError.notFound + } + guard status == errSecSuccess, let data = result as? Data else { + throw ProviderAccountStoreError.keychain(status) + } + return try decodeSecret(data) + } + + private static func writeDelete(for accountId: UUID, service: String) throws { + let query = matchQuery(service: service, accountId: accountId) + let status = SecItemDelete(query as CFDictionary) + if status != errSecSuccess && status != errSecItemNotFound { + throw ProviderAccountStoreError.keychain(status) + } + } +} diff --git a/Sources/Providers/ProviderAccountsController.swift b/Sources/Providers/ProviderAccountsController.swift new file mode 100644 index 000000000..40aba608c --- /dev/null +++ b/Sources/Providers/ProviderAccountsController.swift @@ -0,0 +1,373 @@ +import AppKit +import Foundation + +// MARK: - Controller + +@MainActor +final class ProviderAccountsController: ObservableObject { + static let shared = ProviderAccountsController() + + @Published private(set) var snapshots: [UUID: ProviderUsageSnapshot] = [:] + @Published private(set) var fetchErrors: [UUID: String] = [:] + /// Keyed by providerId + @Published private(set) var incidents: [String: [ProviderIncident]] = [:] + /// Keyed by providerId + @Published private(set) var statusLoaded: [String: Bool] = [:] + /// Keyed by providerId + @Published private(set) var statusFetchFailed: [String: Bool] = [:] + /// Keyed by providerId + @Published private(set) var statusHasSucceeded: [String: Bool] = [:] + @Published private(set) var isRefreshing: Bool = false + + /// Hard cap on a single provider usage/status fetch before it is dropped + /// with a timeout error. Prevents one hung backend from pushing the task + /// group past the 60s tick cadence and dropping later scheduled refreshes. + private static let perFetchTimeout: TimeInterval = 20 + + private let queue = DispatchQueue(label: "com.cmuxterm.provider-accounts.timer") + private var timer: DispatchSourceTimer? + private var tickCount: Int = 0 + private var currentTask: Task? + private var taskGeneration: Int = 0 + private var occlusionObserver: NSObjectProtocol? + private var wasVisible: Bool = false + /// Flips to `true` during `stop()` so any timer / occlusion callback that + /// was already queued when teardown ran is dropped before it can reopen a + /// tick and fire extra provider requests. + private var isStopped: Bool = true + /// Flips to `true` when a timer/occlusion tick arrives while `currentTask` + /// is still draining. Instead of dropping the signal we run one more tick + /// as soon as the current one finishes, so a long fetch can't swallow the + /// next scheduled refresh. + private var hasPendingTick: Bool = false + + private init() {} + + // MARK: - Public API + + func start() { + guard timer == nil else { return } + isStopped = false + + let source = DispatchSource.makeTimerSource(queue: queue) + source.schedule(deadline: .now(), repeating: 60.0) + source.setEventHandler { [weak self] in + Task { @MainActor [weak self] in + guard let self, !self.isStopped else { return } + self.scheduleTick() + } + } + timer = source + source.resume() + + // Resume polling immediately when app transitions from hidden to visible. + // Only fire on hidden→visible edges to avoid refresh churn when the + // occlusion state toggles frequently (e.g. during window drags). + wasVisible = NSApp.occlusionState.contains(.visible) + occlusionObserver = NotificationCenter.default.addObserver( + forName: NSApplication.didChangeOcclusionStateNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + guard let self, !self.isStopped else { return } + let isVisible = NSApp.occlusionState.contains(.visible) + if isVisible && !self.wasVisible { + self.scheduleTick(force: true) + } + self.wasVisible = isVisible + } + } + } + + func stop() { + isStopped = true + timer?.cancel() + timer = nil + currentTask?.cancel() + currentTask = nil + hasPendingTick = false + tickCount = 0 + if let observer = occlusionObserver { + NotificationCenter.default.removeObserver(observer) + occlusionObserver = nil + } + wasVisible = false + } + + func refreshNow() { + scheduleTick(force: true) + } + + // MARK: - Private + + private func scheduleTick(force: Bool = false) { + // `refreshNow()` routes through here too, so the stopped gate has to + // live inside `scheduleTick` rather than only in the callback paths — + // otherwise an explicit manual refresh could restart polling after + // teardown. + guard !isStopped else { return } + if currentTask != nil && !force { + // A refresh is already in flight. Record the signal and run one + // more tick when the current task drains, instead of silently + // dropping it and waiting another 60s. + hasPendingTick = true + return + } + // A forced refresh replaces any in-flight task, so an earlier queued + // signal is already subsumed by the new run — clearing the flag stops + // the completion handler from scheduling a redundant second pass. + hasPendingTick = false + currentTask?.cancel() + taskGeneration += 1 + let generation = taskGeneration + currentTask = Task { @MainActor [weak self] in + guard let self else { return } + await self.tick(generation: generation, force: force) + if self.taskGeneration == generation { + self.currentTask = nil + if self.hasPendingTick && !self.isStopped { + self.hasPendingTick = false + self.scheduleTick() + } + } + } + } + + private func tick(generation: Int, force: Bool = false) async { + // Skip if app is occluded (not visible), unless forced + if !force { + if !NSApp.occlusionState.contains(.visible) { + return + } + } + + // Bail out if a newer task has already replaced us + guard taskGeneration == generation else { return } + + isRefreshing = true + defer { + // Only clear isRefreshing if no newer task has replaced us + if taskGeneration == generation || currentTask == nil { + isRefreshing = false + } + } + + let accounts = ProviderAccountStore.shared.accounts + + // Skip all network requests when no accounts are configured + guard !accounts.isEmpty else { + snapshots.removeAll() + fetchErrors.removeAll() + incidents.removeAll() + statusLoaded.removeAll() + statusFetchFailed.removeAll() + statusHasSucceeded.removeAll() + return + } + + // Usage fetches run in parallel so one slow provider can't starve the + // others. Results are applied back on the MainActor after the group + // completes. + let fetchResults = await withTaskGroup(of: (ProviderAccount, Result?).self) { group -> [(ProviderAccount, Result?)] in + for account in accounts { + guard let provider = ProviderRegistry.provider(id: account.providerId) else { + group.addTask { (account, nil) } + continue + } + group.addTask { + do { + // Keychain retrieval is on the same timer budget as + // the network fetch so a stalled keychain access can't + // block the refresh cadence any longer than a hung + // provider call would. + let result = try await ProviderAccountsController.withTimeout(seconds: Self.perFetchTimeout) { + let secret = try await ProviderAccountStore.shared.secret(for: account.id) + return try await provider.fetchUsage(secret) + } + return (account, .success(result)) + } catch { + return (account, .failure(error)) + } + } + } + var out: [(ProviderAccount, Result?)] = [] + for await item in group { + out.append(item) + } + return out + } + + guard !Task.isCancelled, taskGeneration == generation else { return } + + // Re-read the store after the await: the user may have removed + // accounts or providers while fetches were in flight, and those + // results must not be written back into the published state. + let liveAccountIds = Set(ProviderAccountStore.shared.accounts.map(\.id)) + let liveProviderIds = Set(ProviderAccountStore.shared.accounts.map(\.providerId)) + + for (account, outcome) in fetchResults { + guard liveAccountIds.contains(account.id) else { continue } + switch outcome { + case nil: + fetchErrors[account.id] = String( + localized: "providers.accounts.error.unknownProvider", + defaultValue: "Unknown provider: \(account.providerId)" + ) + case .success(let result): + snapshots[account.id] = ProviderUsageSnapshot( + accountId: account.id, + providerId: account.providerId, + displayName: account.displayName, + session: result.session, + week: result.week, + fetchedAt: Date() + ) + fetchErrors.removeValue(forKey: account.id) + case .failure(_ as CancellationError): + continue + case .failure(let error): + fetchErrors[account.id] = Self.localizedFetchErrorMessage(error) + } + } + + // Clean up snapshots/errors for removed accounts. + // Collect stale keys first — mutating a Dictionary while iterating its + // Keys view is undefined behavior. + let staleSnapshotIds = snapshots.keys.filter { !liveAccountIds.contains($0) } + for id in staleSnapshotIds { + snapshots.removeValue(forKey: id) + } + let staleErrorIds = fetchErrors.keys.filter { !liveAccountIds.contains($0) } + for id in staleErrorIds { + fetchErrors.removeValue(forKey: id) + } + + // Clean up provider-keyed status state for providers with no remaining accounts + let staleIncidentProviderIds = incidents.keys.filter { !liveProviderIds.contains($0) } + for providerId in staleIncidentProviderIds { + incidents.removeValue(forKey: providerId) + } + let staleLoadedProviderIds = statusLoaded.keys.filter { !liveProviderIds.contains($0) } + for providerId in staleLoadedProviderIds { + statusLoaded.removeValue(forKey: providerId) + } + let staleFailedProviderIds = statusFetchFailed.keys.filter { !liveProviderIds.contains($0) } + for providerId in staleFailedProviderIds { + statusFetchFailed.removeValue(forKey: providerId) + } + let staleSucceededProviderIds = statusHasSucceeded.keys.filter { !liveProviderIds.contains($0) } + for providerId in staleSucceededProviderIds { + statusHasSucceeded.removeValue(forKey: providerId) + } + + // Fetch status incidents every 5th tick (every ~5 minutes) + tickCount += 1 + if tickCount % 5 == 1 || force { + let providerIds = liveProviderIds + let statusResults = await withTaskGroup(of: (String, Result<[ProviderIncident], Error>?).self) { group -> [(String, Result<[ProviderIncident], Error>?)] in + for providerId in providerIds { + guard let provider = ProviderRegistry.provider(id: providerId), + let fetchStatus = provider.fetchStatus else { + group.addTask { (providerId, nil) } + continue + } + group.addTask { + do { + let fetched = try await ProviderAccountsController.withTimeout(seconds: Self.perFetchTimeout) { + try await fetchStatus() + } + return (providerId, .success(fetched)) + } catch { + return (providerId, .failure(error)) + } + } + } + var out: [(String, Result<[ProviderIncident], Error>?)] = [] + for await item in group { + out.append(item) + } + return out + } + + guard !Task.isCancelled, taskGeneration == generation else { return } + + // Re-check which providers still have an account so a provider + // removed during the status fetch cannot reopen its status state. + let currentProviderIds = Set(ProviderAccountStore.shared.accounts.map(\.providerId)) + + for (providerId, outcome) in statusResults { + guard currentProviderIds.contains(providerId) else { continue } + switch outcome { + case nil: + continue + case .success(let fetched): + incidents[providerId] = fetched + statusLoaded[providerId] = true + statusFetchFailed[providerId] = false + statusHasSucceeded[providerId] = true + case .failure: + // Keep previous incidents on failure; mark loaded so UI stops showing spinner + statusLoaded[providerId] = true + statusFetchFailed[providerId] = true + } + } + } + } + + // MARK: - Error mapping + + /// Ensures every string exposed through `fetchErrors` is routed through + /// the app's `.xcstrings` catalog. Provider-owned errors already return + /// localized `errorDescription` values; any other error is wrapped in a + /// localized shell so raw OS-level strings never reach the UI. + private static func localizedFetchErrorMessage(_ error: Error) -> String { + if let localized = error as? LocalizedError, + let description = localized.errorDescription { + return description + } + return String( + localized: "providers.accounts.error.fetchFailed", + defaultValue: "Could not refresh usage: \(error.localizedDescription)" + ) + } + + // MARK: - Timeout helper + + struct ProviderFetchTimeoutError: Error, LocalizedError { + let seconds: TimeInterval + var errorDescription: String? { + String( + localized: "providers.accounts.error.timeout", + defaultValue: "Provider fetch timed out after \(Int(seconds))s." + ) + } + } + + /// Runs `operation` with a deadline. When the sibling timer wins the + /// group is cancelled and `ProviderFetchTimeoutError` is thrown. Swift's + /// structured concurrency still awaits the operation task, so a timely + /// exit requires `operation` to cooperate with cancellation — URLSession + /// fetches do this automatically (they throw `URLError.cancelled`), and + /// the keychain helpers check `Task.isCancelled` at each entry point so a + /// new call after the timer fires exits immediately. A keychain call + /// already mid-flight runs to completion; `SecItem*` is not itself + /// interruptible. + nonisolated static func withTimeout( + seconds: TimeInterval, + operation: @Sendable @escaping () async throws -> T + ) async throws -> T { + try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { try await operation() } + group.addTask { + try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + throw ProviderFetchTimeoutError(seconds: seconds) + } + guard let value = try await group.next() else { + throw ProviderFetchTimeoutError(seconds: seconds) + } + group.cancelAll() + return value + } + } +} diff --git a/Sources/Providers/ProviderHTTP.swift b/Sources/Providers/ProviderHTTP.swift new file mode 100644 index 000000000..a1bd3ab49 --- /dev/null +++ b/Sources/Providers/ProviderHTTP.swift @@ -0,0 +1,96 @@ +import Foundation + +// MARK: - Shared HTTP plumbing for provider fetchers +// +// Claude, Codex, and Statuspage.io fetchers all need the same shape: an +// ephemeral URLSession (no disk cookie/cache storage for credentials), a +// short timeout, a GET that expects `application/json`, a response-type +// check, an HTTP 200 gate, and JSON decoding. Centralizing it here keeps +// each concrete fetcher focused on its provider-specific parsing. + +enum ProviderHTTPError: Error { + case badResponse + case http(Int) + case decoding + case network(Error) +} + +enum ProviderHTTP { + + /// Returns a fresh ephemeral `URLSession` with the requested request / + /// resource timeout (seconds). Ephemeral sessions don't persist cookies + /// or URL cache to disk, which is important for credential-bearing + /// requests — a crashed process mustn't leave a plaintext cookie in + /// `~/Library/Caches`. + static func makeSession(timeout: TimeInterval) -> URLSession { + let config = URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest = timeout + config.timeoutIntervalForResource = timeout + config.httpCookieStorage = nil + config.httpShouldSetCookies = false + config.urlCache = nil + config.requestCachePolicy = .reloadIgnoringLocalCacheData + return URLSession(configuration: config) + } + + /// GETs `url` with optional `Cookie` / `Authorization` / `chatgpt-account-id` + /// style headers (already sanitized by the caller) and returns the JSON + /// top-level object. Header values pass through `sanitizeHeaderValue`, + /// which strips control characters and cookie attribute separators so a + /// malformed credential can't smuggle extra directives into the request. + static func getJSONObject( + url: URL, + headers: [String: String] = [:], + session: URLSession + ) async throws -> [String: Any] { + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("application/json", forHTTPHeaderField: "Accept") + for (name, value) in headers { + request.setValue(sanitizeHeaderValue(value), forHTTPHeaderField: name) + } + + let data: Data + let response: URLResponse + do { + (data, response) = try await session.data(for: request) + } catch is CancellationError { + throw CancellationError() + } catch let urlError as URLError where urlError.code == .cancelled { + // URLSession surfaces cooperative task cancellation as + // `URLError.cancelled`; forward it as `CancellationError` so + // callers can handle cancellation through one code path. + throw CancellationError() + } catch { + // Defensive: if the surrounding Task was cancelled, treat any + // resulting error as cancellation rather than a network failure. + if Task.isCancelled { + throw CancellationError() + } + throw ProviderHTTPError.network(error) + } + + guard let httpResponse = response as? HTTPURLResponse else { + throw ProviderHTTPError.badResponse + } + guard httpResponse.statusCode == 200 else { + throw ProviderHTTPError.http(httpResponse.statusCode) + } + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw ProviderHTTPError.decoding + } + return json + } + + /// Strips every byte that would change the semantics of an HTTP header: + /// control characters (including `\r\n\t\0`) and cookie attribute + /// separators (`;` and `,`). Upstream provider fetchers already validate + /// credential shape, but this acts as a last-line defense so a malformed + /// value can never inject extra directives into the wire request. + static func sanitizeHeaderValue(_ value: String) -> String { + var disallowed = CharacterSet.controlCharacters + disallowed.insert(charactersIn: ";,") + let scalars = value.unicodeScalars.filter { !disallowed.contains($0) } + return String(String.UnicodeScalarView(scalars)) + } +} diff --git a/Sources/Providers/ProviderISO8601DateParser.swift b/Sources/Providers/ProviderISO8601DateParser.swift new file mode 100644 index 000000000..295027d86 --- /dev/null +++ b/Sources/Providers/ProviderISO8601DateParser.swift @@ -0,0 +1,22 @@ +import Foundation + +// MARK: - Shared ISO8601 Date Parser + +enum ProviderISO8601DateParser { + /// Formatters are created per call so concurrent parses from different + /// provider fetch tasks can never share mutable `ISO8601DateFormatter` + /// state. `ISO8601DateFormatter` is not documented as thread-safe, and + /// this parser is intentionally lightweight so the extra allocations are + /// cheaper than the alternatives (locks, thread-local caches). + static func parse(_ string: String?) -> Date? { + guard let string else { return nil } + let withFractional = ISO8601DateFormatter() + withFractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = withFractional.date(from: string) { + return date + } + let withoutFractional = ISO8601DateFormatter() + withoutFractional.formatOptions = [.withInternetDateTime] + return withoutFractional.date(from: string) + } +} diff --git a/Sources/Providers/ProviderRegistry.swift b/Sources/Providers/ProviderRegistry.swift new file mode 100644 index 000000000..cd00a83ff --- /dev/null +++ b/Sources/Providers/ProviderRegistry.swift @@ -0,0 +1,21 @@ +import Foundation + +// MARK: - Provider Registry + +enum ProviderRegistry { + /// All registered providers (including stubs with empty credentialFields). + /// Each concrete provider appends itself in its own source commit so this + /// file stays buildable in isolation. + static var all: [UsageProvider] { [] } + + /// Providers ready for use in the UI — excludes stubs with empty credentialFields. + static var ui: [UsageProvider] { all.filter { !$0.credentialFields.isEmpty } } + + static func provider(id: String) -> UsageProvider? { + all.first { $0.id == id } + } +} + +// MARK: - Providers namespace + +enum Providers {} diff --git a/Sources/Providers/ProviderUsageColorSettings.swift b/Sources/Providers/ProviderUsageColorSettings.swift new file mode 100644 index 000000000..53c7d0cb1 --- /dev/null +++ b/Sources/Providers/ProviderUsageColorSettings.swift @@ -0,0 +1,206 @@ +import SwiftUI + +// MARK: - Color Settings + +@MainActor +final class ProviderUsageColorSettings: ObservableObject { + static let shared = ProviderUsageColorSettings() + + private static let keyLow = "cmux.provider.usageColor.low" + private static let keyMid = "cmux.provider.usageColor.mid" + private static let keyHigh = "cmux.provider.usageColor.high" + private static let keyLowMidThreshold = "cmux.provider.usageColor.lowMidThreshold" + private static let keyMidHighThreshold = "cmux.provider.usageColor.midHighThreshold" + private static let keyInterpolate = "cmux.provider.usageColor.interpolate" + + static let defaultLowHex = "#46B46E" + static let defaultMidHex = "#D2AA3C" + static let defaultHighHex = "#DC5050" + + /// Resolved `Color` value for each built-in threshold color. Kept as + /// derived constants so the Settings color pickers, the live preview, and + /// `color(for:)` all use the exact same fallback when a persisted hex + /// value fails to parse. + static var defaultLowColor: Color { Color(usageHex: defaultLowHex) ?? .green } + static var defaultMidColor: Color { Color(usageHex: defaultMidHex) ?? .yellow } + static var defaultHighColor: Color { Color(usageHex: defaultHighHex) ?? .red } + private static let defaultLowMidThreshold = 85 + private static let defaultMidHighThreshold = 95 + + private let defaults: UserDefaults + + @Published var lowColorHex: String { + didSet { defaults.set(lowColorHex, forKey: Self.keyLow) } + } + + @Published var midColorHex: String { + didSet { defaults.set(midColorHex, forKey: Self.keyMid) } + } + + @Published var highColorHex: String { + didSet { defaults.set(highColorHex, forKey: Self.keyHigh) } + } + + /// Writes are funneled through `setThresholds(low:high:)` so the `1...99` + /// contract and the `low < high` ordering invariant can be enforced in one + /// place. Direct external writes are disallowed to keep out-of-range or + /// inverted pairs from bypassing that validation. + @Published private(set) var lowMidThreshold: Int { + didSet { defaults.set(lowMidThreshold, forKey: Self.keyLowMidThreshold) } + } + + @Published private(set) var midHighThreshold: Int { + didSet { defaults.set(midHighThreshold, forKey: Self.keyMidHighThreshold) } + } + + @Published var interpolate: Bool { + didSet { defaults.set(interpolate, forKey: Self.keyInterpolate) } + } + + private convenience init() { + self.init(userDefaults: .standard) + } + + /// Test-only initializer. Keeps production code on `.shared` but lets + /// `cmuxTests/ProviderTests.swift` point the settings at an isolated + /// `UserDefaults` suite so it never pollutes or reads from the user's + /// real defaults domain. + init(userDefaults: UserDefaults) { + self.defaults = userDefaults + self.lowColorHex = userDefaults.string(forKey: Self.keyLow) ?? Self.defaultLowHex + self.midColorHex = userDefaults.string(forKey: Self.keyMid) ?? Self.defaultMidHex + self.highColorHex = userDefaults.string(forKey: Self.keyHigh) ?? Self.defaultHighHex + + var lowMid = (userDefaults.object(forKey: Self.keyLowMidThreshold) as? Int) ?? Self.defaultLowMidThreshold + var midHigh = (userDefaults.object(forKey: Self.keyMidHighThreshold) as? Int) ?? Self.defaultMidHighThreshold + lowMid = min(max(lowMid, 1), 98) + midHigh = min(max(midHigh, 2), 99) + if lowMid >= midHigh { + lowMid = Self.defaultLowMidThreshold + midHigh = Self.defaultMidHighThreshold + } + self.lowMidThreshold = lowMid + self.midHighThreshold = midHigh + + if userDefaults.object(forKey: Self.keyInterpolate) != nil { + self.interpolate = userDefaults.bool(forKey: Self.keyInterpolate) + } else { + self.interpolate = true + } + } + + // MARK: - Color Resolution + + func color(for percent: Int) -> Color { + let clamped = min(max(percent, 0), 100) + let lowColor = Color(usageHex: lowColorHex) ?? Self.defaultLowColor + let midColor = Color(usageHex: midColorHex) ?? Self.defaultMidColor + let highColor = Color(usageHex: highColorHex) ?? Self.defaultHighColor + + if !interpolate { + if clamped <= lowMidThreshold { + return lowColor + } else if clamped <= midHighThreshold { + return midColor + } else { + return highColor + } + } + + // Interpolation mode + if clamped <= lowMidThreshold { + let t = lowMidThreshold > 0 + ? Double(clamped) / Double(lowMidThreshold) + : 0.0 + return interpolateColor(from: lowColor, to: midColor, t: t) + } else if clamped <= midHighThreshold { + let range = midHighThreshold - lowMidThreshold + let t = range > 0 + ? Double(clamped - lowMidThreshold) / Double(range) + : 0.0 + return interpolateColor(from: midColor, to: highColor, t: t) + } else { + return highColor + } + } + + // MARK: - Threshold Validation + + func setThresholds(low: Int, high: Int) { + guard low >= 1, high <= 99, low < high else { return } + lowMidThreshold = low + midHighThreshold = high + } + + // MARK: - Reset + + func resetToDefaults() { + lowColorHex = Self.defaultLowHex + midColorHex = Self.defaultMidHex + highColorHex = Self.defaultHighHex + setThresholds(low: Self.defaultLowMidThreshold, high: Self.defaultMidHighThreshold) + interpolate = true + } + + // MARK: - Color Interpolation + + private func interpolateColor(from: Color, to: Color, t: Double) -> Color { + let clampedT = min(max(t, 0), 1) + let fromComponents = from.rgbComponents + let toComponents = to.rgbComponents + return Color( + red: fromComponents.red + (toComponents.red - fromComponents.red) * clampedT, + green: fromComponents.green + (toComponents.green - fromComponents.green) * clampedT, + blue: fromComponents.blue + (toComponents.blue - fromComponents.blue) * clampedT + ) + } +} + +// MARK: - Color Hex Extension + +extension Color { + init?(usageHex hex: String) { + // Accept exactly zero or one leading `#`. `trimmingCharacters` would + // have silently normalized junk like `###RRGGBB` or `#RRGGBB#`; + // requiring a tight shape lets corrupted persisted colors fail closed. + let sanitized: String + if hex.hasPrefix("#") { + sanitized = String(hex.dropFirst()) + } else { + sanitized = hex + } + guard sanitized.count == 6, let value = UInt64(sanitized, radix: 16) else { return nil } + self.init( + red: Double((value >> 16) & 0xFF) / 255.0, + green: Double((value >> 8) & 0xFF) / 255.0, + blue: Double(value & 0xFF) / 255.0 + ) + } + + var usageHexString: String { + let components = rgbComponents + let r = Int(round(components.red * 255)) + let g = Int(round(components.green * 255)) + let b = Int(round(components.blue * 255)) + return String(format: "#%02X%02X%02X", r, g, b) + } + + /// Returns the sRGB red/green/blue components of this color in the 0...1 range. + /// + /// If the color cannot be converted to the sRGB color space (for example, pattern + /// colors or asset-catalog dynamic colors that lack a concrete sRGB representation), + /// this falls back to opaque black `(0, 0, 0)`. + var rgbComponents: (red: Double, green: Double, blue: Double) { + guard let nsColor = NSColor(self).usingColorSpace(.sRGB) else { + #if DEBUG + NSLog("[ProviderUsageColorSettings] rgbComponents: color is not sRGB-convertible, falling back to black. color=%@", String(describing: self)) + #endif + return (red: 0, green: 0, blue: 0) + } + return ( + red: Double(nsColor.redComponent), + green: Double(nsColor.greenComponent), + blue: Double(nsColor.blueComponent) + ) + } +} diff --git a/Sources/Providers/StatuspageIOFetcher.swift b/Sources/Providers/StatuspageIOFetcher.swift new file mode 100644 index 000000000..30b4a931b --- /dev/null +++ b/Sources/Providers/StatuspageIOFetcher.swift @@ -0,0 +1,80 @@ +import Foundation + +// MARK: - Errors + +enum StatuspageIOFetchError: Error { + case http(Int) + case decoding + case network(Error) + case invalidHost(String) +} + +// MARK: - Fetcher + +enum StatuspageIOFetcher { + + private static let session = ProviderHTTP.makeSession(timeout: 5) + + /// Hosts permitted as Statuspage.io API origins. Must be kept in sync with + /// the `fetchStatus` closures in each registered provider. + private static let allowedHosts: Set = [ + "status.claude.com", + "status.openai.com", + ] + + /// Fetches unresolved incidents from a Statuspage.io-compatible API. + /// When `componentFilter` is provided, only incidents affecting at least one + /// of the listed component names are returned. + static func fetch(host: String, componentFilter: Set? = nil) async throws -> [ProviderIncident] { + guard !host.contains("/"), !host.contains(":"), allowedHosts.contains(host) else { + throw StatuspageIOFetchError.invalidHost(host) + } + guard let url = URL(string: "https://\(host)/api/v2/incidents.json") else { + throw StatuspageIOFetchError.decoding + } + + let json: [String: Any] + do { + json = try await ProviderHTTP.getJSONObject(url: url, session: session) + } catch is CancellationError { + throw CancellationError() + } catch ProviderHTTPError.http(let status) { + throw StatuspageIOFetchError.http(status) + } catch ProviderHTTPError.badResponse { + throw StatuspageIOFetchError.decoding + } catch ProviderHTTPError.network(let underlying) { + throw StatuspageIOFetchError.network(underlying) + } catch { + throw StatuspageIOFetchError.decoding + } + guard let incidents = json["incidents"] as? [[String: Any]] else { + throw StatuspageIOFetchError.decoding + } + + let closedStatuses: Set = ["resolved", "postmortem"] + return incidents.compactMap { dict in + let status = dict["status"] as? String ?? "unknown" + guard !closedStatuses.contains(status) else { return nil } + guard let id = dict["id"] as? String, + let name = dict["name"] as? String else { + return nil + } + + if let filter = componentFilter { + let components = dict["components"] as? [[String: Any]] ?? [] + // The component intersection only applies when the payload + // attaches a component list; page-wide incidents arrive with an + // empty components array and should always be surfaced. + if !components.isEmpty { + let componentNames = Set(components.compactMap { $0["name"] as? String }) + guard !componentNames.isDisjoint(with: filter) else { return nil } + } + } + + let impact = dict["impact"] as? String ?? "none" + let updatedAt = ProviderISO8601DateParser.parse(dict["updated_at"] as? String) + return ProviderIncident(id: id, name: name, status: status, impact: impact, updatedAt: updatedAt) + } + } +} + diff --git a/Sources/Providers/UsageProvider.swift b/Sources/Providers/UsageProvider.swift new file mode 100644 index 000000000..4bae91b6f --- /dev/null +++ b/Sources/Providers/UsageProvider.swift @@ -0,0 +1,60 @@ +import Foundation + +// MARK: - Usage Windows + +struct ProviderUsageWindows: Sendable { + let session: ProviderUsageWindow + let week: ProviderUsageWindow +} + +struct ProviderUsageWindow: Sendable { + let utilization: Int // 0..100, "% used" + let resetsAt: Date? + let windowSeconds: TimeInterval // 18000 for session, 604800 for week +} + +// MARK: - Incidents + +struct ProviderIncident: Identifiable, Sendable { + let id: String + let name: String + let status: String + let impact: String + let updatedAt: Date? +} + +// MARK: - Usage Snapshot + +struct ProviderUsageSnapshot { + let accountId: UUID + let providerId: String + let displayName: String + let session: ProviderUsageWindow + let week: ProviderUsageWindow + let fetchedAt: Date +} + +// MARK: - Credential Field + +struct CredentialField: Identifiable, Sendable { + let id: String // dictionary key, e.g. "sessionKey", "orgId", "apiKey" + let label: String // localized + let placeholder: String + let isSecret: Bool // SecureField vs TextField + let helpText: String? // shown under the field + let validate: (@Sendable (String) -> Bool)? +} + +// MARK: - Usage Provider + +struct UsageProvider: Identifiable, Sendable { + let id: String // "claude" | "codex" | ... + let displayName: String // "Claude" | "Codex" + let keychainService: String // "com.cmuxterm.app.claude-accounts" + let credentialFields: [CredentialField] + let statusPageURL: URL? // for the popover "open status page" button + let statusSectionTitle: String // localized popover header, e.g. "Claude.ai status" + let helpDocURL: URL? // for the editor sheet help link + let fetchUsage: @Sendable (ProviderSecret) async throws -> ProviderUsageWindows + let fetchStatus: (@Sendable () async throws -> [ProviderIncident])? +} diff --git a/Sources/Sidebar/ProviderAccountEditorSheet.swift b/Sources/Sidebar/ProviderAccountEditorSheet.swift new file mode 100644 index 000000000..69e95245a --- /dev/null +++ b/Sources/Sidebar/ProviderAccountEditorSheet.swift @@ -0,0 +1,210 @@ +import AppKit +import SwiftUI + +// MARK: - Editor Sheet + +struct ProviderAccountEditorSheet: View { + let provider: UsageProvider + let editingAccount: ProviderAccount? + let onDismiss: () -> Void + + @State private var displayName: String = "" + @State private var values: [String: String] = [:] + @State private var errorMessage: String? + @State private var isSaving: Bool = false + @State private var isLoadingCredentials: Bool = false + + private var isEditing: Bool { editingAccount != nil } + + private var isValid: Bool { + let nameOk = !displayName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + // A provider with no credential fields cannot produce a meaningful + // ProviderSecret — reject save rather than persisting an empty payload. + guard !provider.credentialFields.isEmpty else { return false } + let fieldsOk = provider.credentialFields.allSatisfy { field in + let value = values[field.id]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if let validate = field.validate { + return validate(value) + } + return !value.isEmpty + } + return nameOk && fieldsOk + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack(alignment: .firstTextBaseline) { + Text(isEditing + ? String( + localized: "providers.accounts.editor.title.edit", + defaultValue: "Edit \(provider.displayName) account" + ) + : String( + localized: "providers.accounts.editor.title.add", + defaultValue: "Add \(provider.displayName) account" + ) + ) + .font(.headline) + + Spacer() + + if let helpDocURL = provider.helpDocURL { + Button { + NSWorkspace.shared.open(helpDocURL) + } label: { + HStack(spacing: 4) { + Image(systemName: "questionmark.circle") + Text(String( + localized: "providers.accounts.help.link", + defaultValue: "Setup instructions" + )) + } + .font(.caption) + } + .buttonStyle(.link) + } + } + + VStack(alignment: .leading, spacing: 10) { + VStack(alignment: .leading, spacing: 4) { + let nameLabel = String( + localized: "providers.accounts.editor.name", + defaultValue: "Display name" + ) + Text(nameLabel) + .font(.caption) + .foregroundColor(.secondary) + TextField("", text: $displayName) + .textFieldStyle(.roundedBorder) + .accessibilityLabel(nameLabel) + } + + ForEach(provider.credentialFields) { field in + VStack(alignment: .leading, spacing: 4) { + Text(field.label) + .font(.caption) + .foregroundColor(.secondary) + // Credential inputs are locked while the existing + // secret is being read back from Keychain so an async + // load completion can't clobber characters the user + // started typing. + if field.isSecret { + SecureField(field.placeholder, text: fieldBinding(for: field.id)) + .textFieldStyle(.roundedBorder) + .accessibilityLabel(field.label) + .disabled(isLoadingCredentials) + } else { + TextField(field.placeholder, text: fieldBinding(for: field.id)) + .textFieldStyle(.roundedBorder) + .accessibilityLabel(field.label) + .disabled(isLoadingCredentials) + } + if let helpText = field.helpText { + Text(helpText) + .font(.caption2) + .foregroundColor(.secondary) + } + } + } + } + + if let errorMessage { + Text(errorMessage) + .font(.caption) + .foregroundColor(.red) + } + + HStack { + Spacer() + // Cancel is locked while a save is in flight so the user + // can't dismiss the sheet halfway through a keychain write + // and be surprised by credentials still landing on disk. + Button(String(localized: "providers.accounts.editor.cancel", defaultValue: "Cancel")) { + onDismiss() + } + .buttonStyle(.bordered) + .keyboardShortcut(.cancelAction) + .disabled(isSaving) + + Button(String(localized: "providers.accounts.editor.save", defaultValue: "Save")) { + guard !isSaving else { return } + isSaving = true + Task { await save() } + } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + .disabled(!isValid || isSaving) + } + } + .padding(20) + .frame(width: 380) + .task { + guard let account = editingAccount else { return } + displayName = account.displayName + isLoadingCredentials = true + defer { isLoadingCredentials = false } + do { + let secret = try await ProviderAccountStore.shared.secret(for: account.id) + for field in provider.credentialFields { + values[field.id] = secret.fields[field.id] ?? "" + } + } catch let storeError as ProviderAccountStoreError { + errorMessage = storeError.localizedDescription + } catch { + errorMessage = String( + localized: "providers.accounts.error.loadSecret", + defaultValue: "Could not load saved credentials. Re-enter them to save changes." + ) + } + } + } + + private func fieldBinding(for key: String) -> Binding { + Binding( + get: { values[key] ?? "" }, + set: { values[key] = $0 } + ) + } + + private func save() async { + errorMessage = nil + + let trimmedName = displayName.trimmingCharacters(in: .whitespacesAndNewlines) + var trimmedFields: [String: String] = [:] + for field in provider.credentialFields { + let trimmed = (values[field.id] ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + trimmedFields[field.id] = trimmed + } + } + + let secret = ProviderSecret(fields: trimmedFields) + + do { + if let account = editingAccount { + try await ProviderAccountStore.shared.update(id: account.id, displayName: trimmedName, secret: secret) + } else { + try await ProviderAccountStore.shared.add( + providerId: provider.id, + displayName: trimmedName, + secret: secret + ) + } + ProviderAccountsController.shared.refreshNow() + onDismiss() + } catch let storeError as ProviderAccountStoreError { + errorMessage = storeError.localizedDescription + isSaving = false + } catch { + // An unexpected error type reached here; show a purely localized + // message so raw OS-level strings never leak into the sheet. The + // raw error still lands in Console via NSLog for debugging. + NSLog("ProviderAccountEditorSheet: save failed: \(error)") + errorMessage = String( + localized: "providers.accounts.error.saveFailed", + defaultValue: "Could not save credentials. Please try again." + ) + isSaving = false + } + } +} diff --git a/Sources/Sidebar/ProviderAccountsFooterPanel.swift b/Sources/Sidebar/ProviderAccountsFooterPanel.swift new file mode 100644 index 000000000..cf97a0d37 --- /dev/null +++ b/Sources/Sidebar/ProviderAccountsFooterPanel.swift @@ -0,0 +1,645 @@ +import AppKit +import SwiftUI + +// MARK: - Footer Panel + +struct ProviderAccountsFooterPanel: View { + @ObservedObject private var store = ProviderAccountStore.shared + @ObservedObject private var controller = ProviderAccountsController.shared + + private var providers: [UsageProvider] { ProviderRegistry.ui } + + private var providersWithAccounts: [UsageProvider] { + let configuredIds = Set(store.accounts.map { $0.providerId }) + return providers.filter { configuredIds.contains($0.id) } + } + + var body: some View { + if providersWithAccounts.isEmpty { + EmptyView() + } else { + VStack(alignment: .leading, spacing: 4) { + ForEach(providersWithAccounts, id: \.id) { provider in + ProviderSection(provider: provider, store: store, controller: controller) + } + } + } + } +} + +// MARK: - Provider Section + +private struct ProviderSection: View { + let provider: UsageProvider + @ObservedObject var store: ProviderAccountStore + @ObservedObject var controller: ProviderAccountsController + + @State private var isPopoverShown = false + @State private var isCollapsed: Bool + + init(provider: UsageProvider, store: ProviderAccountStore, controller: ProviderAccountsController) { + self.provider = provider + self.store = store + self.controller = controller + + let key = "cmux.providers.accounts.collapsed.\(provider.id)" + _isCollapsed = State(initialValue: UserDefaults.standard.bool(forKey: key)) + } + + private var collapsedKey: String { "cmux.providers.accounts.collapsed.\(provider.id)" } + + private var providerAccounts: [ProviderAccount] { + store.accounts.filter { $0.providerId == provider.id } + } + + var body: some View { + // The parent footer only mounts `ProviderSection` for providers with + // at least one configured account, so the accounts list is guaranteed + // to be non-empty once this view renders. + VStack(alignment: .leading, spacing: 4) { + header + if !isCollapsed { + accountsList + } + } + .padding(.vertical, 4) + .padding(.horizontal, 4) + .popover(isPresented: $isPopoverShown, arrowEdge: .top) { + ProviderAccountsPopover( + provider: provider, + store: store, + controller: controller, + isPresented: $isPopoverShown + ) + } + } + + private var header: some View { + Button { + toggleCollapsed() + } label: { + HStack(spacing: 4) { + Image(systemName: isCollapsed ? "chevron.right" : "chevron.down") + .font(.system(size: 9, weight: .semibold)) + .foregroundColor(.secondary) + .frame(width: 10, alignment: .center) + Text(provider.displayName) + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(.secondary) + .textCase(.uppercase) + if provider.fetchStatus != nil { + ProviderStatusLabel(provider: provider, controller: controller) + } + Spacer(minLength: 0) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(isCollapsed + ? String(localized: "providers.accounts.header.expand", defaultValue: "Expand \(provider.displayName) accounts") + : String(localized: "providers.accounts.header.collapse", defaultValue: "Collapse \(provider.displayName) accounts") + ) + } + + private var accountsList: some View { + // A real `Button` is used so keyboard focus and VoiceOver both treat + // the stack as an actionable control that opens the detailed popover, + // not just a mouse-only surface. + Button { + isPopoverShown.toggle() + } label: { + VStack(alignment: .leading, spacing: 6) { + ForEach(providerAccounts) { account in + AccountRow( + account: account, + snapshot: controller.snapshots[account.id], + errorMessage: controller.fetchErrors[account.id] + ) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(String(localized: "providers.accounts.footer.accessibility", defaultValue: "\(provider.displayName) account usage")) + } + + private func toggleCollapsed() { + let newValue = !isCollapsed + withAnimation(.easeInOut(duration: 0.15)) { + isCollapsed = newValue + } + UserDefaults.standard.set(newValue, forKey: collapsedKey) + } +} + +// MARK: - Provider Status Label (Header) + +private struct ProviderStatusLabel: View { + let provider: UsageProvider + @ObservedObject var controller: ProviderAccountsController + + private var providerIncidents: [ProviderIncident] { + controller.incidents[provider.id] ?? [] + } + + private var isStatusLoaded: Bool { + controller.statusLoaded[provider.id] ?? false + } + + private var isStatusFetchFailed: Bool { + controller.statusFetchFailed[provider.id] ?? false + } + + private var hasStatusSucceeded: Bool { + controller.statusHasSucceeded[provider.id] ?? false + } + + var body: some View { + HStack(spacing: 3) { + Text("·") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.quaternary) + Circle() + .fill(dotColor) + .frame(width: 5, height: 5) + Text(statusText) + .font(.system(size: 9)) + .foregroundStyle(.tertiary) + .lineLimit(1) + } + .help(tooltipText) + .accessibilityLabel(statusText) + } + + private var statusText: String { + if !isStatusLoaded { + return String(localized: "provider.status.loading", defaultValue: "…") + } + // When the latest status fetch failed, surface that directly rather + // than falling through to a cached "Operational"/incident reading. A + // stale snapshot could hide a real outage that landed between polls. + if isStatusFetchFailed { + return String(localized: "provider.status.unknown", defaultValue: "Unknown") + } + if providerIncidents.isEmpty { + return String(localized: "provider.status.operational", defaultValue: "Operational") + } + let worst = providerIncidents + .map { Self.impactSeverity($0.impact) } + .max() ?? 0 + switch worst { + case 0: return String(localized: "provider.status.operational", defaultValue: "Operational") + case 1: return String(localized: "provider.status.minor", defaultValue: "Minor issue") + case 2: return String(localized: "provider.status.degraded", defaultValue: "Degraded") + default: return String(localized: "provider.status.critical", defaultValue: "Critical") + } + } + + private var dotColor: Color { + if !isStatusLoaded || isStatusFetchFailed { + return .gray + } + if providerIncidents.isEmpty { + return .green + } + let worst = providerIncidents + .map { Self.impactSeverity($0.impact) } + .max() ?? 0 + switch worst { + case 0: return .green + case 1: return .yellow + case 2: return .orange + default: return .red + } + } + + private var tooltipText: String { + if !isStatusLoaded { + return String(localized: "providers.accounts.status.loading", defaultValue: "Checking status…") + } + // Match the header's failure semantics: when the latest fetch failed, + // surface that directly rather than reading from a cached snapshot + // that could hide a real outage landing between polls. + if isStatusFetchFailed { + return String(localized: "providers.accounts.status.fetchFailed", defaultValue: "Could not check status") + } + if providerIncidents.isEmpty { + return String(localized: "providers.accounts.status.allOk", defaultValue: "All systems operational") + } + if providerIncidents.count == 1, let only = providerIncidents.first { + return only.name + } + let names = providerIncidents.prefix(3).map(\.name).joined(separator: "\n") + let extra = providerIncidents.count > 3 + ? "\n" + String(localized: "providers.accounts.incidents.truncated", defaultValue: "…") + : "" + return names + extra + } + + private static func impactSeverity(_ impact: String) -> Int { + switch impact.lowercased() { + case "none": return 0 + case "minor": return 1 + case "major": return 2 + case "critical": return 3 + default: return 1 + } + } +} + +// MARK: - Shared label width +// +// "Sess" and "Week" fit in a narrow fixed label column when rendered in +// English, but translations ("Сессия", "Неделя", etc.) can be noticeably +// wider and would either truncate or wrap. The two usage rows in a single +// account card publish their intrinsic label width through this preference +// key; the card takes the max and feeds it back to both rows so they share +// the widest-localized label column and the bar shrinks to fit the rest. +// +// This type is module-internal (not file-private) so that +// `ProviderAccountsPopover.swift` can reuse the same key on its own usage +// rows — both surfaces render "Sess"/"Week" and should share the mechanism. + +struct UsageLabelWidthPreferenceKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = max(value, nextValue()) + } +} + +// MARK: - Account Row + +private struct AccountRow: View { + let account: ProviderAccount + let snapshot: ProviderUsageSnapshot? + let errorMessage: String? + + @State private var sharedLabelWidth: CGFloat = 0 + + var body: some View { + VStack(alignment: .leading, spacing: 3) { + Text(account.displayName) + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(.primary) + .lineLimit(1) + + if let errorMessage { + HStack(spacing: 4) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 10)) + .foregroundColor(.red) + Text(errorMessage) + .font(.system(size: 10)) + .foregroundColor(.red) + .lineLimit(1) + .truncationMode(.tail) + } + .safeHelp(errorMessage) + } else if let snapshot { + VStack(spacing: 1) { + UsageRow( + label: String(localized: "providers.accounts.footer.session", defaultValue: "Sess"), + window: snapshot.session, + sharedLabelWidth: sharedLabelWidth + ) + UsageRow( + label: String(localized: "providers.accounts.footer.week", defaultValue: "Week"), + window: snapshot.week, + sharedLabelWidth: sharedLabelWidth + ) + } + .onPreferenceChange(UsageLabelWidthPreferenceKey.self) { width in + sharedLabelWidth = width + } + } else { + Text(String(localized: "providers.accounts.footer.loading", defaultValue: "loading…")) + .font(.system(size: 10)) + .foregroundColor(.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +// MARK: - Usage Row + +private struct UsageRow: View { + @ObservedObject private var colorSettings = ProviderUsageColorSettings.shared + + let label: String + let window: ProviderUsageWindow + /// Width that both rows in a single account card share, so the "Sess" + /// and "Week" labels render at the same width regardless of locale. + /// Starts at 0; the parent reads the max intrinsic label width via a + /// preference key and writes it back here. + let sharedLabelWidth: CGFloat + + private var percent: Int { window.utilization } + + private var pacePercent: Int? { + guard let resetsAt = window.resetsAt else { return nil } + guard window.windowSeconds > 0 else { return nil } + let remaining = max(0, resetsAt.timeIntervalSinceNow) + let elapsed = max(0, min(window.windowSeconds, window.windowSeconds - remaining)) + return Int((elapsed * 100) / window.windowSeconds) + } + + private var metaText: String { + let unavailable = String( + localized: "providers.accounts.countdown.unavailable", + defaultValue: "—" + ) + guard let resetsAt = window.resetsAt else { + return unavailable + } + let interval = resetsAt.timeIntervalSince(Date()) + guard interval > 0 else { + return unavailable + } + + let totalMinutes = Int(interval) / 60 + let hours = totalMinutes / 60 + let minutes = totalMinutes % 60 + + // Week-scale windows render as `Nd` / `Nd Nh` while at least 24h + // remain, so the column keeps day-level granularity but still shows + // the hour remainder (e.g. `2d 22h` vs a misleading flat `2d`). + // Smaller windows and the final day of a week window surface the + // hour/minute form so the user can see precision when it matters. + // Sub-minute remainders render as "<1m" so the countdown keeps a + // visible value right up to the reset instead of falling to "0m". + let isMultiDayWindow = window.windowSeconds >= 86_400 + if isMultiDayWindow && hours >= 24 { + let days = hours / 24 + let remainderHours = hours % 24 + return remainderHours == 0 + ? CountdownUnitFormat.days(days) + : CountdownUnitFormat.daysHoursSpaced(days: days, hours: remainderHours) + } + if hours > 0 { + return minutes > 0 + ? CountdownUnitFormat.hoursMinutesSpaced(hours: hours, minutes: minutes) + : CountdownUnitFormat.hours(hours) + } + return minutes > 0 + ? CountdownUnitFormat.minutes(minutes) + : CountdownUnitFormat.lessThanMinute() + } + + var body: some View { + HStack(spacing: 4) { + Text(label) + .font(.system(size: 10, weight: .regular, design: .monospaced)) + .foregroundColor(.secondary) + .fixedSize(horizontal: true, vertical: false) + .background( + GeometryReader { proxy in + Color.clear.preference( + key: UsageLabelWidthPreferenceKey.self, + value: proxy.size.width + ) + } + ) + .frame( + width: sharedLabelWidth > 0 ? sharedLabelWidth : nil, + alignment: .leading + ) + + Text(String( + localized: "providers.accounts.usage.percent", + defaultValue: "\(percent)%" + )) + .font(.system(size: 10, weight: .semibold, design: .monospaced)) + .foregroundColor(colorSettings.color(for: percent)) + .frame(width: 28, alignment: .trailing) + + GeometryReader { geometry in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 2) + .fill(Color.primary.opacity(0.08)) + + RoundedRectangle(cornerRadius: 2) + .fill(colorSettings.color(for: percent).opacity(0.7)) + .frame(width: max(0, geometry.size.width * CGFloat(min(max(percent, 0), 100)) / 100.0)) + + if let pacePercent { + Rectangle() + .fill(Color.primary.opacity(0.4)) + .frame(width: 1) + .offset(x: max(0, min(geometry.size.width - 1, geometry.size.width * CGFloat(pacePercent) / 100.0))) + } + } + } + .frame(height: 5) + + Text(metaText) + .font(.system(size: 9, weight: .medium, design: .monospaced)) + .foregroundColor(.secondary) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + // Width fits the widest compact countdown (`NNh NNm`, `Nd NNh`) + // rendered at size-9 monospaced without truncation; longer + // localized strings expand past the minimum via fixedSize. + .frame(minWidth: 46, alignment: .trailing) + } + .safeHelp(tooltipText) + } + + private var tooltipText: String { + let percentText = String( + localized: "providers.accounts.usage.percent", + defaultValue: "\(percent)%" + ) + let summary = String( + localized: "providers.accounts.usage.summary", + defaultValue: "\(label) \(percentText)" + ) + // Only present the reset phrase for an upcoming reset. A resetsAt in + // the past means the server-side window rolled over but we haven't + // fetched a refresh yet; pairing it with "resets