diff --git a/LookInside.xcodeproj/project.pbxproj b/LookInside.xcodeproj/project.pbxproj index 1a80531d..f6531b67 100644 --- a/LookInside.xcodeproj/project.pbxproj +++ b/LookInside.xcodeproj/project.pbxproj @@ -22,6 +22,7 @@ E972100D2FC2E5C0003AAEA6 /* InjectionServiceInterface in Frameworks */ = {isa = PBXBuildFile; productRef = E972100C2FC2E5C0003AAEA6 /* InjectionServiceInterface */; }; E9FE990C3026C657008692AF /* UIFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = E9FE990B3026C657008692AF /* UIFoundation */; }; E9FE990E3026C657008692AF /* UIFoundationToolbox in Frameworks */ = {isa = PBXBuildFile; productRef = E9FE990D3026C657008692AF /* UIFoundationToolbox */; }; + E9FE99103026C657008692AF /* UIFoundationSettingsUI in Frameworks */ = {isa = PBXBuildFile; productRef = E9FE990F3026C657008692AF /* UIFoundationSettingsUI */; }; F0F0A10E2FD0000100A1B2C3 /* FoundationToolbox in Frameworks */ = {isa = PBXBuildFile; productRef = F0F0A10D2FD0000100A1B2C3 /* FoundationToolbox */; }; /* End PBXBuildFile section */ @@ -193,9 +194,6 @@ Measure/MeasureController.swift, Measure/MeasureResultView.swift, Other/VersionComparer.swift, - Preference/PreferenceView.swift, - Preference/PreferenceWindowController.swift, - PrivateDiscriminator/PrivateDiscriminatorSettingsWindowController.swift, PrivateDiscriminator/PrivateDiscriminatorStore.swift, Read/ArchiveCoding.swift, Read/ArchiveDocument.swift, @@ -204,6 +202,9 @@ Read/ReadScreenshotLookup.swift, Read/ReadViewController.swift, Read/ReadWindowController.swift, + Settings/GeneralSettingsPage.swift, + Settings/PrivateDiscriminatorSettingsPage.swift, + Settings/SettingsPages.swift, Static/MessageManager.swift, Static/Preview/DisplayItemNode.swift, Static/Preview/PreviewController.swift, @@ -299,6 +300,7 @@ E972100B2FC2E5C0003AAEA6 /* HelperCommunication in Frameworks */, E972100D2FC2E5C0003AAEA6 /* InjectionServiceInterface in Frameworks */, E9FE990E3026C657008692AF /* UIFoundationToolbox in Frameworks */, + E9FE99103026C657008692AF /* UIFoundationSettingsUI in Frameworks */, F0F0A10E2FD0000100A1B2C3 /* FoundationToolbox in Frameworks */, 15FFB4D96CB7375162952FFE /* LookInsideActivation in Frameworks */, 79CC3F736CA547232F1827F4 /* LookInsideActivationUI in Frameworks */, @@ -366,6 +368,7 @@ E972100C2FC2E5C0003AAEA6 /* InjectionServiceInterface */, E9FE990B3026C657008692AF /* UIFoundation */, E9FE990D3026C657008692AF /* UIFoundationToolbox */, + E9FE990F3026C657008692AF /* UIFoundationSettingsUI */, F0F0A10D2FD0000100A1B2C3 /* FoundationToolbox */, 4CF60A9D44E04A6BE91035EF /* LookInsideActivation */, F05DCA7C93CF923C02603F72 /* LookInsideActivationUI */, @@ -884,6 +887,7 @@ }; traits = ( AppleInternal, + Settings, ); }; F0F0A10A2FD0000100A1B2C3 /* XCRemoteSwiftPackageReference "FrameworkToolbox" */ = { @@ -952,6 +956,11 @@ package = E9FE990A3026C657008692AF /* XCRemoteSwiftPackageReference "UIFoundation" */; productName = UIFoundationToolbox; }; + E9FE990F3026C657008692AF /* UIFoundationSettingsUI */ = { + isa = XCSwiftPackageProductDependency; + package = E9FE990A3026C657008692AF /* XCRemoteSwiftPackageReference "UIFoundation" */; + productName = UIFoundationSettingsUI; + }; F05DCA7C93CF923C02603F72 /* LookInsideActivationUI */ = { isa = XCSwiftPackageProductDependency; package = E81BCA16A46AEA38803B27B7 /* XCLocalSwiftPackageReference "Packages/LookInsideActivation" */; diff --git a/LookInside/DebugE2E/DebugUISnapshots.swift b/LookInside/DebugE2E/DebugUISnapshots.swift index 244933b9..bf206868 100644 --- a/LookInside/DebugE2E/DebugUISnapshots.swift +++ b/LookInside/DebugE2E/DebugUISnapshots.swift @@ -217,10 +217,10 @@ try await readRoundTrip(exported) // Windows of their own. - NavigationManager.shared.showPreference() - try await captureStandaloneWindow("preferences", controllerKey: "preferenceWindowController") + NavigationManager.shared.showSettings() + try await captureStandaloneWindow("settings") { NavigationManager.shared.settingsWindowController } NavigationManager.shared.showAbout() - try await captureStandaloneWindow("about", controllerKey: "aboutWindowController") + try await captureStandaloneWindow("about") { NavigationManager.shared.aboutWindowController } try await captureActivationWindow() } @@ -276,10 +276,9 @@ window.close() } - private func captureStandaloneWindow(_ name: String, controllerKey: String) async throws { - let navigation = NavigationManager.shared + private func captureStandaloneWindow(_ name: String, controller: () -> NSWindowController?) async throws { let window = try await waitFor("the \(name) window") { - (navigation.value(forKey: controllerKey) as? NSWindowController)?.window.flatMap { $0.isVisible ? $0 : nil } + controller()?.window.flatMap { $0.isVisible ? $0 : nil } } quietWindows() window.center() diff --git a/LookInside/Localizable.xcstrings b/LookInside/Localizable.xcstrings index d0640747..b8822b5f 100644 --- a/LookInside/Localizable.xcstrings +++ b/LookInside/Localizable.xcstrings @@ -1,9 +1,6 @@ { "sourceLanguage" : "en", "strings" : { - "" : { - - }, " / " : { "comment" : "Separator between imported/autosaved CSV sources", "extractionState" : "stale", @@ -8342,6 +8339,89 @@ } } }, + "Dark" : { + "comment" : "Caption under the dark appearance thumbnail in the Settings window.", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "داكن" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dunkel" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dark" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oscuro" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sombre" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scuro" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ダーク" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "다크" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Escuro" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Тёмный" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tối" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "深色" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "深色" + } + } + } + }, "Dark Mode" : { "localizations" : { "ar" : { @@ -9241,6 +9321,7 @@ } }, "Done" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -13927,6 +14008,89 @@ } } }, + "General" : { + "comment" : "Title of the Settings window's General page.", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "عام" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Allgemein" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "General" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "General" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Général" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Generali" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "一般" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "일반" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geral" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Основные" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cài đặt chung" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "通用" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "一般" + } + } + } + }, "Geometry" : { "comment" : "Native region detail field title", "localizations" : { @@ -15002,6 +15166,89 @@ } } }, + "Hierarchy" : { + "comment" : "Header of the Settings window's group of hierarchy settings.", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "التسلسل الهرمي" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hierarchie" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hierarchy" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jerarquía" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hiérarchie" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gerarchia" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "階層" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "계층 구조" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hierarquia" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Иерархия" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Phân cấp" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "层级" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "階層" + } + } + } + }, "Hierarchy Depth" : { "comment" : "View menu submenu", "localizations" : { @@ -19858,6 +20105,89 @@ } } }, + "Light" : { + "comment" : "Caption under the light appearance thumbnail in the Settings window.", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "فاتح" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hell" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Light" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Claro" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Clair" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chiaro" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ライト" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "라이트" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Claro" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Светлый" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sáng" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "浅色" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "淺色" + } + } + } + }, "Light Mode" : { "localizations" : { "ar" : { @@ -22327,6 +22657,89 @@ } } }, + "Modules" : { + "comment" : "Header of the Private Discriminator settings group that lists the imported modules.", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الوحدات" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Module" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modules" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Módulos" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modules" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Moduli" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "モジュール" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "모듈" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Módulos" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Модули" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mô-đun" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "模块" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "模組" + } + } + } + }, "More than one connected device has a LookInside Injector ready." : { "comment" : "Alert body in device chooser", "localizations" : { @@ -27278,6 +27691,7 @@ } }, "Preferences" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -27361,6 +27775,7 @@ }, "Preferences…" : { "comment" : "App menu item", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -27690,6 +28105,7 @@ } }, "Private Discriminator" : { + "comment" : "Title of the Settings window's Private Discriminator page.", "localizations" : { "ar" : { "stringUnit" : { @@ -27772,6 +28188,7 @@ } }, "Private Discriminator Settings" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -27855,6 +28272,7 @@ }, "Private Discriminator Settings…" : { "comment" : "App menu item", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -30079,6 +30497,172 @@ } } }, + "Reset Settings" : { + "comment" : "Title of the row on the Settings window's General page whose button resets the page's settings.", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "إعادة تعيين الإعدادات" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Einstellungen zurücksetzen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reset Settings" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Restablecer ajustes" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Réinitialiser les réglages" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ripristina impostazioni" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "設定をリセット" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "설정 재설정" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Redefinir Ajustes" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сбросить настройки" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đặt lại cài đặt" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "重置设置" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "重置設定" + } + } + } + }, + "Resets the settings on this page to their default values." : { + "comment" : "Description under Reset Settings on the Settings window's General page.", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "يعيد تعيين الإعدادات في هذه الصفحة إلى قيمها الافتراضية." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Setzt die Einstellungen auf dieser Seite auf ihre Standardwerte zurück." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Resets the settings on this page to their default values." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Restablece los ajustes de esta página a sus valores predeterminados." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Réinitialise les réglages de cette page à leurs valeurs par défaut." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ripristina le impostazioni di questa pagina ai valori predefiniti." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "このページの設定をデフォルト値にリセットします。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 페이지의 설정을 기본값으로 재설정합니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Redefine os ajustes desta página para os valores padrão." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сбрасывает настройки на этой странице к значениям по умолчанию." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đặt lại các cài đặt trên trang này về giá trị mặc định." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "将本页的设置重置为默认值。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "將本頁的設定重置為預設值。" + } + } + } + }, "Resolve Swift private-discriminator hashes from imported module CSVs. Disabled by default." : { "localizations" : { "ar" : { @@ -32059,6 +32643,172 @@ } } }, + "Settings" : { + "comment" : "Title of the Settings window.", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الإعدادات" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Einstellungen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Settings" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajustes" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Réglages" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impostazioni" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "設定" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "설정" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajustes" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройки" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cài đặt" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "设置" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "設定" + } + } + } + }, + "Settings…" : { + "comment" : "App menu item", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الإعدادات…" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Einstellungen …" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Settings…" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajustes…" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Réglages…" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impostazioni…" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "設定…" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "설정…" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajustes…" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройки…" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cài đặt…" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "设置…" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "設定…" + } + } + } + }, "Shape parameters or path are unavailable. The overlay shows dashed layout bounds without a hit-area fill." : { "comment" : "Node detail note", "localizations" : { @@ -35106,6 +35856,89 @@ } } }, + "System" : { + "comment" : "Caption under the appearance thumbnail that follows the system's light or dark setting, in the Settings window.", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "النظام" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "System" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "System" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sistema" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Système" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sistema" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "システム" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "시스템" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sistema" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Система" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hệ thống" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "系统" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "系統" + } + } + } + }, "System Default" : { "localizations" : { "ar" : { @@ -39530,6 +40363,89 @@ } } }, + "Timeouts" : { + "comment" : "Header of the Settings window's group of request timeouts.", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "المهلات" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zeitlimits" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Timeouts" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tiempos de espera" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Délais d’attente" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Timeout" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "タイムアウト" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "제한 시간" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tempos Limite" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Тайм-ауты" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thời gian chờ" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "超时" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "逾時" + } + } + } + }, "to measure between it and selected %@." : { "localizations" : { "ar" : { diff --git a/LookInside/Manager/AppMenuManager.swift b/LookInside/Manager/AppMenuManager.swift index 9b49439a..c0a96a0b 100644 --- a/LookInside/Manager/AppMenuManager.swift +++ b/LookInside/Manager/AppMenuManager.swift @@ -43,7 +43,7 @@ final class AppMenuManager: NSObject, NSMenuDelegate { private enum Tag { static let about = 11 - static let preferences = 12 + static let settings = 12 static let checkUpdates = 13 static let activateSwiftUISupport = 14 static let swiftUISupportLicense = 15 @@ -51,7 +51,6 @@ final class AppMenuManager: NSObject, NSMenuDelegate { static let purchaseSwiftUISupport = 17 static let swiftUISupportCustomerSupport = 18 static let swiftUISupportSubmenu = 19 - static let privateDiscriminatorSettings = 20 static let reload = 21 static let dimension = 22 @@ -152,8 +151,7 @@ final class AppMenuManager: NSObject, NSMenuDelegate { menu.delegate = self menu.addItem(item(String(format: NSLocalizedString("About %@", comment: ""), appName), action: #selector(handleAbout), tag: Tag.about)) menu.addItem(.separator()) - menu.addItem(item(NSLocalizedString("Preferences…", comment: ""), action: #selector(handlePreferences), key: ",", modifiers: .command, tag: Tag.preferences)) - menu.addItem(item(NSLocalizedString("Private Discriminator Settings…", comment: ""), action: #selector(handlePrivateDiscriminatorSettings), tag: Tag.privateDiscriminatorSettings)) + menu.addItem(item(NSLocalizedString("Settings…", comment: ""), action: #selector(handleSettings), key: ",", modifiers: .command, tag: Tag.settings)) menu.addItem(.separator()) menu.addItem(item(NSLocalizedString("Check for Updates…", comment: ""), action: #selector(handleCheckUpdates), tag: Tag.checkUpdates)) menu.addItem(.separator()) @@ -432,12 +430,8 @@ final class AppMenuManager: NSObject, NSMenuDelegate { NavigationManager.shared.showAbout() } - @objc private func handlePreferences() { - NavigationManager.shared.showPreference() - } - - @objc private func handlePrivateDiscriminatorSettings() { - PrivateDiscriminatorSettingsWindowController.showSettingsWindow() + @objc private func handleSettings() { + NavigationManager.shared.showSettings() } @objc private func handleCheckUpdates() { diff --git a/LookInside/Manager/NavigationManager.swift b/LookInside/Manager/NavigationManager.swift index 01c1da71..4a02e253 100644 --- a/LookInside/Manager/NavigationManager.swift +++ b/LookInside/Manager/NavigationManager.swift @@ -5,11 +5,12 @@ // Created by Li Kai on 2018/11/3. // https://lookin.work // -// Opens the app's standalone windows: launch, preferences, about, the JSON +// Opens the app's standalone windows: launch, settings, about, the JSON // viewer, and readers for `.lookin` files or in-memory hierarchies. // import AppKit +import UIFoundationSettingsUI @MainActor final class NavigationManager: NSObject, NSWindowDelegate { @@ -21,9 +22,22 @@ final class NavigationManager: NSObject, NSWindowDelegate { @objc private(set) var launchWindowController: LaunchWindowController? - // Read by name (KVC) by the DEBUG UI snapshots. - @objc private(set) var preferenceWindowController: PreferenceWindowController? - @objc private(set) var aboutWindowController: AboutWindowController? + /// Kept for the app's lifetime rather than dropped on close, so the + /// window opens again on the page the user left it on. + private(set) lazy var settingsWindowController = SettingsWindowController( + configuration: SettingsConfiguration( + title: String(localized: "Settings", comment: "Title of the Settings window."), + // The sidebar is 20 points wider than UIFoundation's default so + // "Private Discriminator" fits; the window grows by as much, so + // the pages keep the default width. + contentWidth: 735, + sidebarWidth: 205, + sidebarIconSize: 15 + ), + pages: { SettingsPages.make() } + ) + + private(set) var aboutWindowController: AboutWindowController? private var jsonWindowController: JSONAttributeWindowController? /// Set by the hierarchy view from its top inset. @@ -45,14 +59,8 @@ final class NavigationManager: NSObject, NSWindowDelegate { launchWindowController = nil } - @objc func showPreference() { - let controller = preferenceWindowController ?? { - let controller = PreferenceWindowController() - controller.window?.delegate = self - return controller - }() - preferenceWindowController = controller - controller.showWindow(self) + @objc func showSettings() { + settingsWindowController.showWindow(self) } @objc func showAbout() { @@ -116,9 +124,7 @@ final class NavigationManager: NSObject, NSWindowDelegate { func windowWillClose(_ notification: Notification) { let closingWindow = notification.object as? NSWindow - if closingWindow == preferenceWindowController?.window { - preferenceWindowController = nil - } else if closingWindow == aboutWindowController?.window { + if closingWindow == aboutWindowController?.window { aboutWindowController = nil } // Live and archive document windows belong to NSDocumentController, diff --git a/LookInside/Preference/PreferenceView.swift b/LookInside/Preference/PreferenceView.swift deleted file mode 100644 index 5daf5d61..00000000 --- a/LookInside/Preference/PreferenceView.swift +++ /dev/null @@ -1,389 +0,0 @@ -import AppKit -import SwiftUI - -public final class PreferenceHostingController: NSViewController { - override public func loadView() { - view = NSHostingView(rootView: PreferenceRootView()) - } -} - -private struct PreferenceRootView: View { - @StateObject private var model = PreferenceViewModel() - - var body: some View { - VStack(spacing: 0) { - ScrollView { - VStack(alignment: .leading, spacing: 0) { - Text("Preferences") - .font(.system(size: 24, weight: .semibold)) - .padding(.bottom, 18) - - PreferenceRow( - title: "Appearance", - message: nil, - controlWidth: 300 - ) { - Picker("Appearance", selection: model.appearanceTypeBinding) { - Text("Dark Mode").tag(0) - Text("Light Mode").tag(1) - Text("System Default").tag(2) - } - .pickerStyle(.segmented) - } - - PreferenceDivider() - - PreferenceRow( - title: "Color Format", - message: model.colorFormatMessage, - controlWidth: 220 - ) { - Picker("Color Format", selection: model.colorFormatBinding) { - Text(verbatim: "RGBA").tag(0) - Text(verbatim: "HEX").tag(1) - } - .pickerStyle(.segmented) - } - - PreferenceDivider() - - PreferenceRow( - title: "Image contrast", - message: "Adjust this option to use a deeper layer selection color.", - controlWidth: 300 - ) { - Picker("Image contrast", selection: model.imageContrastBinding) { - Text("Normal").tag(0) - Text("Medium").tag(1) - Text("High").tag(2) - } - .pickerStyle(.segmented) - } - - PreferenceDivider() - - PreferenceRow( - title: "Double click", - message: nil, - controlWidth: 300 - ) { - Picker("Double click", selection: model.doubleClickBinding) { - Text("Expand or collapse layer").tag(0) - Text("Focus on layer").tag(1) - } - .pickerStyle(.segmented) - } - - PreferenceDivider() - - PreferenceRow( - title: "Remember expansion state per app", - message: "When on, the hierarchy expand/collapse state is restored per target-app bundle id across host and target-app restarts. The 20 most recently inspected apps are kept; toggling off preserves existing data but stops reading or writing it.", - controlWidth: 80 - ) { - Toggle("Remember expansion state per app", isOn: model.rememberExpansionStateBinding) - .toggleStyle(.switch) - } - - PreferenceDivider() - - PreferenceRow( - title: "Hierarchy Timeout", - message: "Timeout for hierarchy and hierarchy-details requests, in seconds. Default: 15s.", - controlWidth: 168 - ) { - TimeoutEditor(value: model.hierarchyTimeoutBinding) - } - - #if DEBUG - PreferenceDivider() - - PreferenceRow( - title: "License Timeout", - message: "Timeout for license challenge and verification requests, in seconds. Default: 5s.", - controlWidth: 168 - ) { - TimeoutEditor(value: model.licenseTimeoutBinding) - } - #endif - } - .padding(.horizontal, 32) - .padding(.top, 28) - .padding(.bottom, 24) - } - - Divider() - - HStack { - Spacer() - Button("Reset", role: .destructive) { - model.reset() - } - .keyboardShortcut(.delete, modifiers: [.command, .shift]) - } - .padding(.horizontal, 24) - .padding(.vertical, 14) - .background(.bar) - } - .frame(minWidth: 640, idealWidth: 660, minHeight: minimumHeight) - .background(Color(nsColor: .windowBackgroundColor)) - .onAppear { - model.reload() - } - } - - private var minimumHeight: CGFloat { - #if DEBUG - 520 - #else - 455 - #endif - } -} - -private struct PreferenceRow: View { - let title: LocalizedStringKey - let message: LocalizedStringKey? - let controlWidth: CGFloat - @ViewBuilder let control: Control - - var body: some View { - HStack(alignment: .firstTextBaseline, spacing: 24) { - VStack(alignment: .leading, spacing: 5) { - Text(title) - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(.primary) - - if let message { - Text(message) - .font(.system(size: 12)) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - - control - .labelsHidden() - .frame(width: controlWidth, alignment: .trailing) - } - .padding(.vertical, 13) - } -} - -private struct PreferenceDivider: View { - var body: some View { - Divider() - .padding(.leading, 0) - } -} - -private struct TimeoutEditor: View { - @Binding var value: Double - - var body: some View { - HStack(spacing: 8) { - TextField(Self.emptyTitle, value: $value, formatter: Self.formatter) - .multilineTextAlignment(.trailing) - .textFieldStyle(.roundedBorder) - .frame(width: 72) - - Text(verbatim: "s") - .foregroundStyle(.secondary) - .frame(width: 12, alignment: .leading) - - Stepper("", value: $value, in: 0.1 ... 120, step: 1) - .labelsHidden() - } - } - - private static let emptyTitle = "" - - private static let formatter: NumberFormatter = { - let formatter = NumberFormatter() - formatter.minimum = 0.1 - formatter.maximum = 120 - formatter.minimumFractionDigits = 0 - formatter.maximumFractionDigits = 2 - formatter.allowsFloats = true - return formatter - }() -} - -/// Stays an ObservableObject: as an @Observable model the segmented pickers -/// laid out narrower than before (the ui-snapshots preferences scene). -@MainActor -private final class PreferenceViewModel: ObservableObject { - @Published private var appearanceType: Int - @Published private var colorFormat: Int - @Published private var imageContrast: Int - @Published private var doubleClickBehavior: Int - @Published private var rememberExpansionState: Bool - @Published private var hierarchyTimeout: Double - - #if DEBUG - @Published private var licenseTimeout: Double - #endif - - private let manager: PreferenceManager - - init(manager: PreferenceManager = PreferenceManager.shared) { - self.manager = manager - appearanceType = manager.appearanceType.rawValue - colorFormat = manager.rgbaFormat ? 0 : 1 - imageContrast = manager.imageContrastLevel - doubleClickBehavior = manager.doubleClickBehavior.rawValue - rememberExpansionState = manager.rememberExpansionState - hierarchyTimeout = manager.hierarchyRequestTimeoutInterval - #if DEBUG - licenseTimeout = manager.licenseHandshakeTimeoutInterval - #endif - } - - var appearanceTypeBinding: Binding { - Binding( - get: { self.appearanceType }, - set: { self.setAppearanceType($0) } - ) - } - - var colorFormatBinding: Binding { - Binding( - get: { self.colorFormat }, - set: { self.setColorFormat($0) } - ) - } - - var imageContrastBinding: Binding { - Binding( - get: { self.imageContrast }, - set: { self.setImageContrast($0) } - ) - } - - var doubleClickBinding: Binding { - Binding( - get: { self.doubleClickBehavior }, - set: { self.setDoubleClickBehavior($0) } - ) - } - - var rememberExpansionStateBinding: Binding { - Binding( - get: { self.rememberExpansionState }, - set: { self.setRememberExpansionState($0) } - ) - } - - var hierarchyTimeoutBinding: Binding { - Binding( - get: { self.hierarchyTimeout }, - set: { self.setHierarchyTimeout($0) } - ) - } - - #if DEBUG - var licenseTimeoutBinding: Binding { - Binding( - get: { self.licenseTimeout }, - set: { self.setLicenseTimeout($0) } - ) - } - #endif - - var colorFormatMessage: LocalizedStringKey { - if colorFormat == 0 { - return "Color will be displayed in format like (255, 12, 34, 0.5). Alpha value is between 0 and 1." - } - return "Color will be displayed in format like #7e7e7eff. The components are #RRGGBBAA." - } - - func reload() { - appearanceType = manager.appearanceType.rawValue - colorFormat = manager.rgbaFormat ? 0 : 1 - imageContrast = manager.imageContrastLevel - doubleClickBehavior = manager.doubleClickBehavior.rawValue - rememberExpansionState = manager.rememberExpansionState - hierarchyTimeout = manager.hierarchyRequestTimeoutInterval - #if DEBUG - licenseTimeout = manager.licenseHandshakeTimeoutInterval - #endif - } - - func reset() { - setAppearanceType(PreferredAppearanceType.system.rawValue) - setColorFormat(0) - setImageContrast(0) - setDoubleClickBehavior(DoubleClickBehavior.collapse.rawValue) - setRememberExpansionState(true) - setHierarchyTimeout(defaultHierarchyRequestTimeoutInterval) - setLicenseTimeoutIfAvailable(defaultLicenseHandshakeTimeoutInterval) - - #if DEBUG - MessageManager.sharedInstance().reset() - manager.reset() - UserDefaults.standard.removeObject(forKey: "IgnoreFastModeTips") - #endif - } - - private func setAppearanceType(_ rawValue: Int) { - guard let type = PreferredAppearanceType(rawValue: rawValue) else { - return - } - appearanceType = rawValue - manager.appearanceType = type - } - - private func setColorFormat(_ rawValue: Int) { - colorFormat = rawValue - manager.rgbaFormat = rawValue == 0 - } - - private func setImageContrast(_ rawValue: Int) { - let clampedValue = min(max(rawValue, 0), 2) - imageContrast = clampedValue - manager.imageContrastLevel = clampedValue - } - - private func setDoubleClickBehavior(_ rawValue: Int) { - guard let behavior = DoubleClickBehavior(rawValue: rawValue) else { - return - } - doubleClickBehavior = rawValue - manager.doubleClickBehavior = behavior - } - - private func setRememberExpansionState(_ newValue: Bool) { - rememberExpansionState = newValue - manager.rememberExpansionState = newValue - } - - private func setHierarchyTimeout(_ value: Double) { - let sanitizedValue = sanitizedTimeout(value, defaultValue: defaultHierarchyRequestTimeoutInterval) - hierarchyTimeout = sanitizedValue - manager.hierarchyRequestTimeoutInterval = sanitizedValue - } - - private func setLicenseTimeoutIfAvailable(_ value: Double) { - #if DEBUG - setLicenseTimeout(value) - #else - _ = value - #endif - } - - #if DEBUG - private func setLicenseTimeout(_ value: Double) { - let sanitizedValue = sanitizedTimeout(value, defaultValue: defaultLicenseHandshakeTimeoutInterval) - licenseTimeout = sanitizedValue - manager.licenseHandshakeTimeoutInterval = sanitizedValue - } - #endif - - private func sanitizedTimeout(_ value: Double, defaultValue: Double) -> Double { - guard value.isFinite, value > 0 else { - return defaultValue - } - return min(max(value, 0.1), 120) - } -} diff --git a/LookInside/Preference/PreferenceWindowController.swift b/LookInside/Preference/PreferenceWindowController.swift deleted file mode 100644 index cd33ca53..00000000 --- a/LookInside/Preference/PreferenceWindowController.swift +++ /dev/null @@ -1,62 +0,0 @@ -// -// PreferenceWindowController.swift -// LookInside -// -// The Preferences window. Its content is the SwiftUI preference form -// (PreferenceView.swift) hosted inside an BaseViewController. -// - -import AppKit - -final class PreferenceWindowController: WindowController { - @objc init() { - #if DEBUG - let windowHeight: CGFloat = 520 - #else - let windowHeight: CGFloat = 455 - #endif - let window = AppWindow(contentRect: NSRect(x: 0, y: 0, width: 660, height: windowHeight), - styleMask: [.titled, .closable, .miniaturizable], - backing: .buffered, defer: true) - window.isMovableByWindowBackground = true - window.title = NSLocalizedString("Preferences", comment: "") - window.titlebarAppearsTransparent = true - window.center() - - super.init(window: window) - let viewController = PreferenceViewController() - window.contentView = viewController.view - contentViewController = viewController - } - - @available(*, unavailable) - required init?(coder _: NSCoder) { - fatalError("init(coder:) is not supported") - } -} - -final class PreferenceViewController: BaseViewController { - init() { - super.init(containerView: nil) - installPreferenceForm() - } - - @available(*, unavailable) - required init?(coder _: NSCoder) { - fatalError("init(coder:) is not supported") - } - - private func installPreferenceForm() { - let hostingController = PreferenceHostingController() - addChild(hostingController) - let hostedView = hostingController.view - hostedView.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(hostedView) - NSLayoutConstraint.activate([ - hostedView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - hostedView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - hostedView.topAnchor.constraint(equalTo: view.topAnchor), - hostedView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - ]) - } -} diff --git a/LookInside/PrivateDiscriminator/PrivateDiscriminatorSettingsWindowController.swift b/LookInside/PrivateDiscriminator/PrivateDiscriminatorSettingsWindowController.swift deleted file mode 100644 index 7de0cd97..00000000 --- a/LookInside/PrivateDiscriminator/PrivateDiscriminatorSettingsWindowController.swift +++ /dev/null @@ -1,392 +0,0 @@ -import AppKit -import SwiftUI - -final class PrivateDiscriminatorSettingsWindowController: NSWindowController { - private static let sharedController = PrivateDiscriminatorSettingsWindowController() - - @objc(showSettingsWindow) - static func showSettingsWindow() { - sharedController.showWindow(nil) - sharedController.window?.makeKeyAndOrderFront(nil) - NSApp.activate(ignoringOtherApps: true) - } - - private init() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 660, height: 640), - styleMask: [.titled, .closable, .miniaturizable, .resizable], - backing: .buffered, - defer: false - ) - window.title = NSLocalizedString("Private Discriminator Settings", comment: "") - window.titlebarAppearsTransparent = true - window.isReleasedWhenClosed = false - window.minSize = NSSize(width: 640, height: 520) - window.contentViewController = NSHostingController(rootView: PrivateDiscriminatorSettingsRootView()) - window.center() - super.init(window: window) - } - - @available(*, unavailable) - required init?(coder _: NSCoder) { - fatalError("init(coder:) has not been implemented") - } -} - -private struct PrivateDiscriminatorSettingsRootView: View { - private var store = PrivateDiscriminatorStore.shared - @State private var importError: String? - - var body: some View { - VStack(spacing: 0) { - ScrollView { - VStack(alignment: .leading, spacing: 0) { - Text("Private Discriminator") - .font(.system(size: 24, weight: .semibold)) - .padding(.bottom, 18) - - SettingsRow( - title: "Enable", - message: "Resolve Swift private-discriminator hashes from imported module CSVs. Disabled by default.", - controlWidth: 84 - ) { - Toggle( - "", - isOn: Binding( - get: { store.featureEnabled }, - set: { store.setFeatureEnabled($0) } - ) - ) - .toggleStyle(.switch) - } - - SettingsDivider() - - SettingsRow( - title: "Default Library", - message: "Download SwiftUI and SwiftUICore indexes from GitHub into local imported CSVs.", - controlWidth: 190 - ) { - Button { - importError = nil - store.updateDefaultLibrary() - } label: { - if store.isUpdatingDefaultLibrary { - Label("Updating…", systemImage: "arrow.down.circle") - } else { - Label("Update Default Library", systemImage: "arrow.down.circle") - } - } - .disabled(store.isUpdatingDefaultLibrary) - } - - if let message = store.lastDefaultLibraryUpdateMessage { - HStack(spacing: 8) { - if store.isUpdatingDefaultLibrary { - ProgressView() - .controlSize(.small) - } - Text(message) - .font(.system(size: 11)) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - .padding(.top, -5) - .padding(.bottom, 13) - } - - if store.featureEnabled { - SettingsDivider() - - SettingsRow( - title: "Autosave", - message: "Allow future guessed rows to use autosaved module CSVs.", - controlWidth: 84 - ) { - Toggle( - "", - isOn: Binding( - get: { store.autosaveEnabled }, - set: { store.setAutosaveEnabled($0) } - ) - ) - .toggleStyle(.switch) - } - - VStack(alignment: .leading, spacing: 10) { - HStack(spacing: 8) { - Button { - beginImport() - } label: { - Label("Import Module", systemImage: "square.and.arrow.down") - } - - Button { - store.revealStorageDirectory() - } label: { - Label("Reveal Storage", systemImage: "folder") - } - - Spacer() - } - - if store.moduleStatuses.isEmpty { - Text("No modules imported yet.") - .font(.system(size: 12)) - .foregroundStyle(.secondary) - } else { - VStack(spacing: 0) { - ForEach(store.moduleStatuses) { status in - ModuleStatusRow(status: status, store: store) { message in - importError = message - } - - if status.id != store.moduleStatuses.last?.id { - Divider() - } - } - } - } - - diagnosticsView - errorView - } - .padding(.vertical, 13) - } - } - .padding(.horizontal, 32) - .padding(.top, 28) - .padding(.bottom, 24) - } - - Divider() - - HStack { - Spacer() - Button("Done") { - NSApp.keyWindow?.performClose(nil) - } - .keyboardShortcut(.defaultAction) - } - .padding(.horizontal, 24) - .padding(.vertical, 14) - .background(.bar) - } - .frame(minWidth: 640, idealWidth: 660, minHeight: 520, idealHeight: 640) - .background(Color(nsColor: .windowBackgroundColor)) - .onAppear { - store.reloadFromDisk() - } - } - - @ViewBuilder - private var diagnosticsView: some View { - if !store.invalidDiagnostics.isEmpty { - VStack(alignment: .leading, spacing: 4) { - Text("Invalid CSV Diagnostics") - .font(.system(size: 12, weight: .medium)) - ForEach(store.invalidDiagnostics) { diagnostic in - Text("\(diagnostic.module) \(diagnostic.source): \(diagnostic.message)") - .font(.system(size: 11)) - .foregroundStyle(.red) - .fixedSize(horizontal: false, vertical: true) - } - } - } - } - - @ViewBuilder - private var errorView: some View { - if let importError { - Text(importError) - .font(.system(size: 11)) - .foregroundStyle(.red) - .fixedSize(horizontal: false, vertical: true) - } else if let error = store.lastSettingsError { - Text(error) - .font(.system(size: 11)) - .foregroundStyle(.red) - .fixedSize(horizontal: false, vertical: true) - } - } - - private func beginImport() { - importError = nil - guard let module = promptForModuleName() else { - return - } - - let panel = NSOpenPanel() - panel.canChooseDirectories = true - panel.canChooseFiles = false - panel.allowsMultipleSelection = false - panel.prompt = NSLocalizedString("Import", comment: "") - panel.message = String( - format: NSLocalizedString("Choose the local Swift source folder for %@.", comment: ""), - module - ) - - guard panel.runModal() == .OK, let url = panel.url else { - return - } - - do { - try store.importModule(named: module, from: url) - } catch { - importError = error.localizedDescription - } - } - - private func promptForModuleName() -> String? { - let alert = NSAlert() - alert.messageText = NSLocalizedString("Import Private Discriminator Module", comment: "") - alert.informativeText = NSLocalizedString("Enter the Swift module name for this source folder.", comment: "") - alert.addButton(withTitle: NSLocalizedString("Continue", comment: "")) - alert.addButton(withTitle: NSLocalizedString("Cancel", comment: "")) - - let textField = NSTextField(frame: NSRect(x: 0, y: 0, width: 280, height: 24)) - textField.placeholderString = NSLocalizedString("ModuleName", comment: "") - alert.accessoryView = textField - - guard alert.runModal() == .alertFirstButtonReturn else { - return nil - } - - let module = textField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) - if module.isEmpty { - importError = NSLocalizedString("Module name is required.", comment: "") - return nil - } - return module - } -} - -private struct SettingsRow: View { - let title: LocalizedStringKey - let message: LocalizedStringKey? - let controlWidth: CGFloat - @ViewBuilder let control: Control - - var body: some View { - HStack(alignment: .firstTextBaseline, spacing: 24) { - VStack(alignment: .leading, spacing: 5) { - Text(title) - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(.primary) - - if let message { - Text(message) - .font(.system(size: 12)) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - - control - .labelsHidden() - .frame(width: controlWidth, alignment: .trailing) - } - .padding(.vertical, 13) - } -} - -private struct SettingsDivider: View { - var body: some View { - Divider() - } -} - -private struct ModuleStatusRow: View { - let status: PrivateDiscriminatorModuleStatus - let store: PrivateDiscriminatorStore - let onError: (String) -> Void - - var body: some View { - HStack(alignment: .center, spacing: 10) { - Toggle( - "", - isOn: Binding( - get: { status.isEnabled }, - set: { store.setModule(status.module, enabled: $0) } - ) - ) - .labelsHidden() - .toggleStyle(.switch) - - VStack(alignment: .leading, spacing: 3) { - Text(status.module) - .font(.system(size: 13, weight: .medium)) - - Text(status.sourceSummary) - .font(.system(size: 11)) - .foregroundStyle(.secondary) - - if let diagnostic = status.diagnostic { - Text(diagnostic) - .font(.system(size: 11)) - .foregroundStyle(.red) - .fixedSize(horizontal: false, vertical: true) - } - } - - Spacer(minLength: 8) - - Button { - reimport() - } label: { - Image(systemName: "arrow.clockwise") - } - .buttonStyle(.borderless) - .help("Reimport from saved source folder") - .disabled(status.sourceFolderPath == nil) - - Button { - store.revealModule(status.module) - } label: { - Image(systemName: "folder") - } - .buttonStyle(.borderless) - .help("Reveal CSV") - - Button(role: .destructive) { - remove() - } label: { - Image(systemName: "trash") - } - .buttonStyle(.borderless) - .help("Remove module CSVs") - } - .padding(.vertical, 8) - } - - private func reimport() { - do { - try store.reimportModule(status.module) - } catch { - onError(error.localizedDescription) - } - } - - private func remove() { - let alert = NSAlert() - alert.messageText = String( - format: NSLocalizedString("Remove %@?", comment: ""), - status.module - ) - alert.informativeText = NSLocalizedString("This removes imported and autosaved CSVs for the module.", comment: "") - alert.addButton(withTitle: NSLocalizedString("Remove", comment: "")) - alert.addButton(withTitle: NSLocalizedString("Cancel", comment: "")) - alert.alertStyle = .warning - - guard alert.runModal() == .alertFirstButtonReturn else { - return - } - - do { - try store.removeModule(status.module) - } catch { - onError(error.localizedDescription) - } - } -} diff --git a/LookInside/PrivateDiscriminator/PrivateDiscriminatorStore.swift b/LookInside/PrivateDiscriminator/PrivateDiscriminatorStore.swift index b344c357..cc6ca15d 100644 --- a/LookInside/PrivateDiscriminator/PrivateDiscriminatorStore.swift +++ b/LookInside/PrivateDiscriminator/PrivateDiscriminatorStore.swift @@ -1008,6 +1008,11 @@ final class PrivateDiscriminatorStore: NSObject { DefaultLibraryModule(name: "SwiftUICore"), ] + /// The modules the default library holds, in the order they download. + static var defaultLibraryModuleNames: [String] { + defaultLibraryModules.map(\.name) + } + private func promptForModuleName(defaultModule: String?, window _: NSWindow?) -> String? { let alert = NSAlert() alert.messageText = NSLocalizedString("Import Private Discriminator Module", comment: "") diff --git a/LookInside/Settings/GeneralSettingsPage.swift b/LookInside/Settings/GeneralSettingsPage.swift new file mode 100644 index 00000000..b00f2672 --- /dev/null +++ b/LookInside/Settings/GeneralSettingsPage.swift @@ -0,0 +1,420 @@ +// +// GeneralSettingsPage.swift +// LookInside +// +// The General page of the Settings window: what the Preferences window +// used to hold. PreferenceManager.shared still owns every value here and +// stores it in the standard user defaults under its original key. +// + +import AppKit +import Foundation +import LookInsideHostCore +import SwiftUI +import UIFoundationSettingsUI + +struct GeneralSettingsPage: View { + let model: GeneralSettingsModel + + var body: some View { + SettingsForm { + GeneralAppearanceSection(model: model) + GeneralColorFormatSection(model: model) + GeneralImageContrastSection(model: model) + GeneralHierarchySection(model: model) + GeneralTimeoutSection(model: model) + #if DEBUG + GeneralLicenseTimeoutSection(model: model) + #endif + GeneralResetSection(model: model) + } + } +} + +/// Dark, light, or whichever the system uses: picked from the pictures +/// System Settings shows for the same choice, or from a menu when this +/// macOS does not have them where they are looked for. +private struct GeneralAppearanceSection: View { + @Bindable var model: GeneralSettingsModel + + var body: some View { + Section { + if let thumbnails = SystemAppearanceThumbnails.current { + LabeledContent("Appearance") { + AppearanceThumbnailPicker(selection: $model.appearanceType, thumbnails: thumbnails) + } + } else { + Picker("Appearance", selection: $model.appearanceType) { + Text("Dark Mode").tag(PreferredAppearanceType.dark) + Text("Light Mode").tag(PreferredAppearanceType.light) + Text("System Default").tag(PreferredAppearanceType.system) + } + } + } + } +} + +/// How colors are written out: RGBA components or a hex string. +private struct GeneralColorFormatSection: View { + @Bindable var model: GeneralSettingsModel + + var body: some View { + Section { + LabeledContent("Color Format") { + Picker("Color Format", selection: $model.usesRGBAColorFormat) { + Text(verbatim: "RGBA").tag(true) + Text(verbatim: "HEX").tag(false) + } + .pickerStyle(.segmented) + .labelsHidden() + .fixedSize() + } + } header: { + Text("Display") + } footer: { + Text(colorFormatDescription) + } + } + + private var colorFormatDescription: LocalizedStringResource { + model.usesRGBAColorFormat + ? "Color will be displayed in format like (255, 12, 34, 0.5). Alpha value is between 0 and 1." + : "Color will be displayed in format like #7e7e7eff. The components are #RRGGBBAA." + } +} + +/// How deep the selected layer's color is. +private struct GeneralImageContrastSection: View { + @Bindable var model: GeneralSettingsModel + + var body: some View { + Section { + LabeledContent("Image contrast") { + Picker("Image contrast", selection: $model.imageContrastLevel) { + Text("Normal").tag(0) + Text("Medium").tag(1) + Text("High").tag(2) + } + .pickerStyle(.segmented) + .labelsHidden() + .fixedSize() + } + } footer: { + Text("Adjust this option to use a deeper layer selection color.") + } + } +} + +/// What a double click does in the hierarchy, and whether its expansion +/// state is kept per app. +private struct GeneralHierarchySection: View { + @Bindable var model: GeneralSettingsModel + + var body: some View { + Section { + Picker("Double click", selection: $model.doubleClickBehavior) { + Text("Expand or collapse layer").tag(DoubleClickBehavior.collapse) + Text("Focus on layer").tag(DoubleClickBehavior.focus) + } + + Toggle("Remember expansion state per app", isOn: $model.remembersExpansionState) + } header: { + Text("Hierarchy") + } footer: { + Text("When on, the hierarchy expand/collapse state is restored per target-app bundle id across host and target-app restarts. The 20 most recently inspected apps are kept; toggling off preserves existing data but stops reading or writing it.") + } + } +} + +/// How long the hierarchy requests may take. +private struct GeneralTimeoutSection: View { + @Bindable var model: GeneralSettingsModel + + var body: some View { + Section { + LabeledContent("Hierarchy Timeout") { + TimeoutStepper("Hierarchy Timeout", seconds: $model.hierarchyRequestTimeout) + } + } header: { + Text("Timeouts") + } footer: { + Text("Timeout for hierarchy and hierarchy-details requests, in seconds. Default: 15s.") + } + } +} + +#if DEBUG + /// The license handshake's timeout, there for debugging the handshake. + private struct GeneralLicenseTimeoutSection: View { + @Bindable var model: GeneralSettingsModel + + var body: some View { + Section { + LabeledContent("License Timeout") { + TimeoutStepper("License Timeout", seconds: $model.licenseHandshakeTimeout) + } + } footer: { + Text("Timeout for license challenge and verification requests, in seconds. Default: 5s.") + } + } + } +#endif + +/// Putting every setting on this page back to its default, laid out the +/// way Xcode's settings offer the same. +private struct GeneralResetSection: View { + let model: GeneralSettingsModel + + var body: some View { + Section { + // An HStack rather than LabeledContent, which would line the + // button up with the title alone instead of centering it on + // both lines. + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("Reset Settings") + .font(.headline) + Text("Resets the settings on this page to their default values.") + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer() + + Button("Reset", role: .destructive) { + model.resetToDefaults() + } + .keyboardShortcut(.delete, modifiers: [.command, .shift]) + } + } header: { + Text("Reset") + } + } +} + +/// Seconds, typed into the field or stepped a whole second at a time. +private struct TimeoutStepper: View { + private let title: LocalizedStringKey + @Binding private var seconds: Double + + init(_ title: LocalizedStringKey, seconds: Binding) { + self.title = title + _seconds = seconds + } + + var body: some View { + Stepper( + title, + value: $seconds, + in: PreferenceValuePolicy.timeoutRange, + step: 1, + format: .number.precision(.fractionLength(0 ... 2)) + ) + .labelsHidden() + } +} + +// MARK: - Appearance thumbnails + +/// The pictures System Settings shows for its three appearance choices. +/// They are Apple's artwork, so the app reads them out of the system's +/// Appearance settings extension at run time instead of shipping copies. +private struct SystemAppearanceThumbnails { + let system: NSImage + let light: NSImage + let dark: NSImage + + /// Nil when this version of macOS has moved or renamed the pictures. + @MainActor static let current = SystemAppearanceThumbnails( + settingsExtensionPath: "/System/Library/ExtensionKit/Extensions/Appearance.appex" + ) + + init?(settingsExtensionPath: String) { + guard + let settingsExtension = Bundle(path: settingsExtensionPath), + let system = settingsExtension.image(forResource: "AppearanceAuto"), + let light = settingsExtension.image(forResource: "AppearanceLight"), + let dark = settingsExtension.image(forResource: "AppearanceDark") + else { + return nil + } + self.system = system + self.light = light + self.dark = dark + } +} + +/// System, light and dark side by side, the chosen one outlined. +private struct AppearanceThumbnailPicker: View { + @Binding var selection: PreferredAppearanceType + let thumbnails: SystemAppearanceThumbnails + + var body: some View { + HStack(spacing: 5) { + AppearanceThumbnailOption(image: thumbnails.system, title: "System", isSelected: selection == .system) { + selection = .system + } + AppearanceThumbnailOption(image: thumbnails.light, title: "Light", isSelected: selection == .light) { + selection = .light + } + AppearanceThumbnailOption(image: thumbnails.dark, title: "Dark", isSelected: selection == .dark) { + selection = .dark + } + } + .padding(.vertical, 8) + } +} + +private struct AppearanceThumbnailOption: View { + let image: NSImage + let title: LocalizedStringKey + let isSelected: Bool + let action: () -> Void + + @State private var isHovering = false + + var body: some View { + Button(action: action) { + VStack(spacing: 8) { + Image(nsImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 72, height: 48) + .clipShape(RoundedRectangle(cornerRadius: 5)) + .overlay { + RoundedRectangle(cornerRadius: 5) + .stroke(isSelected ? Color.accentColor : Color.clear, lineWidth: 3) + } + .shadow(color: .black.opacity(isHovering ? 0.15 : 0.1), radius: isHovering ? 3 : 2, x: 0, y: 1) + + Text(title) + .font(.caption) + .foregroundStyle(isSelected ? .primary : .secondary) + } + } + .buttonStyle(.plain) + .accessibilityAddTraits(isSelected ? .isSelected : []) + .onHover { hovering in + isHovering = hovering + } + } +} + +// MARK: - Model + +/// The General page's side of PreferenceManager.shared. Setting a property +/// writes the manager, and KVO brings every change back, including the ones +/// made elsewhere (the Dashboard switches the color format too), so the page +/// never shows a stale value. +@MainActor +@Observable +final class GeneralSettingsModel { + private let preferences: PreferenceManager + @ObservationIgnored private var observations: [NSKeyValueObservation] = [] + + // What the manager holds, as of its last KVO notification. + private var currentAppearanceType: PreferredAppearanceType + private var currentUsesRGBAColorFormat: Bool + private var currentImageContrastLevel: Int + private var currentDoubleClickBehavior: DoubleClickBehavior + private var currentRemembersExpansionState: Bool + private var currentHierarchyRequestTimeout: TimeInterval + private var currentLicenseHandshakeTimeout: TimeInterval + + init(preferences: PreferenceManager = .shared) { + self.preferences = preferences + currentAppearanceType = preferences.appearanceType + currentUsesRGBAColorFormat = preferences.rgbaFormat + currentImageContrastLevel = preferences.imageContrastLevel + currentDoubleClickBehavior = preferences.doubleClickBehavior + currentRemembersExpansionState = preferences.rememberExpansionState + currentHierarchyRequestTimeout = preferences.hierarchyRequestTimeoutInterval + currentLicenseHandshakeTimeout = preferences.licenseHandshakeTimeoutInterval + + observations = [ + preferences.observe(\.appearanceType, options: [.new]) { [weak self] preferences, _ in + MainActor.assumeIsolated { self?.currentAppearanceType = preferences.appearanceType } + }, + preferences.observe(\.rgbaFormat, options: [.new]) { [weak self] preferences, _ in + MainActor.assumeIsolated { self?.currentUsesRGBAColorFormat = preferences.rgbaFormat } + }, + preferences.observe(\.imageContrastLevel, options: [.new]) { [weak self] preferences, _ in + MainActor.assumeIsolated { self?.currentImageContrastLevel = preferences.imageContrastLevel } + }, + preferences.observe(\.doubleClickBehavior, options: [.new]) { [weak self] preferences, _ in + MainActor.assumeIsolated { self?.currentDoubleClickBehavior = preferences.doubleClickBehavior } + }, + preferences.observe(\.rememberExpansionState, options: [.new]) { [weak self] preferences, _ in + MainActor.assumeIsolated { self?.currentRemembersExpansionState = preferences.rememberExpansionState } + }, + preferences.observe(\.hierarchyRequestTimeoutInterval, options: [.new]) { [weak self] preferences, _ in + MainActor.assumeIsolated { self?.currentHierarchyRequestTimeout = preferences.hierarchyRequestTimeoutInterval } + }, + preferences.observe(\.licenseHandshakeTimeoutInterval, options: [.new]) { [weak self] preferences, _ in + MainActor.assumeIsolated { self?.currentLicenseHandshakeTimeout = preferences.licenseHandshakeTimeoutInterval } + }, + ] + } + + var appearanceType: PreferredAppearanceType { + get { currentAppearanceType } + set { preferences.appearanceType = newValue } + } + + var usesRGBAColorFormat: Bool { + get { currentUsesRGBAColorFormat } + set { preferences.rgbaFormat = newValue } + } + + var imageContrastLevel: Int { + get { currentImageContrastLevel } + set { preferences.imageContrastLevel = PreferenceValuePolicy.clampedImageContrastLevel(newValue) } + } + + var doubleClickBehavior: DoubleClickBehavior { + get { currentDoubleClickBehavior } + set { preferences.doubleClickBehavior = newValue } + } + + var remembersExpansionState: Bool { + get { currentRemembersExpansionState } + set { preferences.rememberExpansionState = newValue } + } + + var hierarchyRequestTimeout: TimeInterval { + get { currentHierarchyRequestTimeout } + set { + preferences.hierarchyRequestTimeoutInterval = PreferenceValuePolicy.sanitizedTimeout( + newValue, defaultValue: defaultHierarchyRequestTimeoutInterval + ) + } + } + + var licenseHandshakeTimeout: TimeInterval { + get { currentLicenseHandshakeTimeout } + set { + preferences.licenseHandshakeTimeoutInterval = PreferenceValuePolicy.sanitizedTimeout( + newValue, defaultValue: defaultLicenseHandshakeTimeoutInterval + ) + } + } + + /// Every value on the page back to its default. Debug builds also forget + /// the dismissed tips, as the Preferences window's Reset did. + func resetToDefaults() { + appearanceType = .system + usesRGBAColorFormat = true + imageContrastLevel = 0 + doubleClickBehavior = .collapse + remembersExpansionState = true + hierarchyRequestTimeout = defaultHierarchyRequestTimeoutInterval + + #if DEBUG + licenseHandshakeTimeout = defaultLicenseHandshakeTimeoutInterval + MessageManager.sharedInstance().reset() + preferences.reset() + UserDefaults.standard.removeObject(forKey: "IgnoreFastModeTips") + #endif + } +} diff --git a/LookInside/Settings/PrivateDiscriminatorSettingsPage.swift b/LookInside/Settings/PrivateDiscriminatorSettingsPage.swift new file mode 100644 index 00000000..81d811b3 --- /dev/null +++ b/LookInside/Settings/PrivateDiscriminatorSettingsPage.swift @@ -0,0 +1,333 @@ +// +// PrivateDiscriminatorSettingsPage.swift +// LookInside +// +// The Private Discriminator page of the Settings window: the feature +// switch, the default library download and the imported modules. +// PrivateDiscriminatorStore.shared owns all of it and its config.json. +// + +import AppKit +import SwiftUI +import UIFoundationSettingsUI + +struct PrivateDiscriminatorSettingsPage: View { + private let store = PrivateDiscriminatorStore.shared + + /// The last import, reimport or removal failure. It outranks the + /// store's own error, which covers loading and saving. + @State private var importError: String? + + var body: some View { + SettingsForm { + PrivateDiscriminatorFeatureSection(store: store) + PrivateDiscriminatorDefaultLibrarySection(store: store, importError: $importError) + + if store.featureEnabled { + PrivateDiscriminatorAutosaveSection(store: store) + PrivateDiscriminatorModulesSection(store: store, importError: $importError) + PrivateDiscriminatorProblemsSection(store: store, importError: importError) + } + } + .onAppear { + store.reloadFromDisk() + } + } +} + +/// The feature switch. +private struct PrivateDiscriminatorFeatureSection: View { + @Bindable var store: PrivateDiscriminatorStore + + var body: some View { + Section { + Toggle("Enable", isOn: $store.isFeatureEnabledSetting) + } footer: { + Text("Resolve Swift private-discriminator hashes from imported module CSVs. Disabled by default.") + } + } +} + +/// Downloading the SwiftUI and SwiftUICore indexes, and how the last +/// download went. +private struct PrivateDiscriminatorDefaultLibrarySection: View { + let store: PrivateDiscriminatorStore + @Binding var importError: String? + + var body: some View { + Section { + // An HStack rather than LabeledContent, so the button stays + // centered on the row once the status adds a second line. + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(PrivateDiscriminatorStore.defaultLibraryModuleNames, format: .list(type: .and)) + + if let message = store.lastDefaultLibraryUpdateMessage { + Text(message) + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + Spacer() + + if store.isUpdatingDefaultLibrary { + ProgressView() + .controlSize(.small) + } + + Button { + importError = nil + store.updateDefaultLibrary() + } label: { + if store.isUpdatingDefaultLibrary { + Label("Updating…", systemImage: "arrow.down.circle") + } else { + Label("Update Default Library", systemImage: "arrow.down.circle") + } + } + .disabled(store.isUpdatingDefaultLibrary) + } + } header: { + Text("Default Library") + } footer: { + Text("Download SwiftUI and SwiftUICore indexes from GitHub into local imported CSVs.") + } + } +} + +private struct PrivateDiscriminatorAutosaveSection: View { + @Bindable var store: PrivateDiscriminatorStore + + var body: some View { + Section { + Toggle("Autosave", isOn: $store.isAutosaveEnabledSetting) + } footer: { + Text("Allow future guessed rows to use autosaved module CSVs.") + } + } +} + +/// Importing a module, and the modules imported so far. +private struct PrivateDiscriminatorModulesSection: View { + let store: PrivateDiscriminatorStore + @Binding var importError: String? + + var body: some View { + Section { + HStack(spacing: 8) { + Button { + beginImport() + } label: { + Label("Import Module", systemImage: "square.and.arrow.down") + } + + Button { + store.revealStorageDirectory() + } label: { + Label("Reveal Storage", systemImage: "folder") + } + + Spacer() + } + + if store.moduleStatuses.isEmpty { + Text("No modules imported yet.") + .foregroundStyle(.secondary) + } else { + ForEach(store.moduleStatuses) { status in + PrivateDiscriminatorModuleRow(status: status, store: store) { message in + importError = message + } + } + } + } header: { + Text("Modules") + } + } + + private func beginImport() { + importError = nil + guard let module = promptForModuleName() else { + return + } + + let panel = NSOpenPanel() + panel.canChooseDirectories = true + panel.canChooseFiles = false + panel.allowsMultipleSelection = false + panel.prompt = String(localized: "Import") + panel.message = String(localized: "Choose the local Swift source folder for \(module).") + + guard panel.runModal() == .OK, let url = panel.url else { + return + } + + do { + try store.importModule(named: module, from: url) + } catch { + importError = error.localizedDescription + } + } + + private func promptForModuleName() -> String? { + let alert = NSAlert() + alert.messageText = String(localized: "Import Private Discriminator Module") + alert.informativeText = String(localized: "Enter the Swift module name for this source folder.") + alert.addButton(withTitle: String(localized: "Continue")) + alert.addButton(withTitle: String(localized: "Cancel")) + + let textField = NSTextField(frame: NSRect(x: 0, y: 0, width: 280, height: 24)) + textField.placeholderString = String(localized: "ModuleName") + alert.accessoryView = textField + + guard alert.runModal() == .alertFirstButtonReturn else { + return nil + } + + let module = textField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + if module.isEmpty { + importError = String(localized: "Module name is required.") + return nil + } + return module + } +} + +/// CSV files that failed to load, and the last error. Each shows only +/// while there is something to report. +private struct PrivateDiscriminatorProblemsSection: View { + let store: PrivateDiscriminatorStore + let importError: String? + + var body: some View { + if !store.invalidDiagnostics.isEmpty { + Section { + ForEach(store.invalidDiagnostics) { diagnostic in + Text("\(diagnostic.module) \(diagnostic.source): \(diagnostic.message)") + .font(.subheadline) + .foregroundStyle(.red) + .fixedSize(horizontal: false, vertical: true) + } + } header: { + Text("Invalid CSV Diagnostics") + } + } + + if let errorMessage { + Section { + Text(errorMessage) + .font(.subheadline) + .foregroundStyle(.red) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + private var errorMessage: String? { + importError ?? store.lastSettingsError + } +} + +private struct PrivateDiscriminatorModuleRow: View { + let status: PrivateDiscriminatorModuleStatus + @Bindable var store: PrivateDiscriminatorStore + let onError: (String) -> Void + + var body: some View { + HStack(alignment: .center, spacing: 10) { + Toggle(status.module, isOn: $store[isModuleEnabled: status.module]) + .labelsHidden() + .toggleStyle(.switch) + + VStack(alignment: .leading, spacing: 3) { + Text(status.module) + + Text(status.sourceSummary) + .font(.subheadline) + .foregroundStyle(.secondary) + + if let diagnostic = status.diagnostic { + Text(diagnostic) + .font(.subheadline) + .foregroundStyle(.red) + .fixedSize(horizontal: false, vertical: true) + } + } + + Spacer(minLength: 8) + + Button { + reimport() + } label: { + Image(systemName: "arrow.clockwise") + } + .buttonStyle(.borderless) + .help("Reimport from saved source folder") + .disabled(status.sourceFolderPath == nil) + + Button { + store.revealModule(status.module) + } label: { + Image(systemName: "folder") + } + .buttonStyle(.borderless) + .help("Reveal CSV") + + Button(role: .destructive) { + remove() + } label: { + Image(systemName: "trash") + } + .buttonStyle(.borderless) + .help("Remove module CSVs") + } + } + + private func reimport() { + do { + try store.reimportModule(status.module) + } catch { + onError(error.localizedDescription) + } + } + + private func remove() { + let alert = NSAlert() + alert.messageText = String(localized: "Remove \(status.module)?") + alert.informativeText = String(localized: "This removes imported and autosaved CSVs for the module.") + alert.addButton(withTitle: String(localized: "Remove")) + alert.addButton(withTitle: String(localized: "Cancel")) + alert.alertStyle = .warning + + guard alert.runModal() == .alertFirstButtonReturn else { + return + } + + do { + try store.removeModule(status.module) + } catch { + onError(error.localizedDescription) + } + } +} + +/// The store's switches as read-write properties, so the page can bind them +/// by key path; writing one saves config.json and reloads the store. +private extension PrivateDiscriminatorStore { + var isFeatureEnabledSetting: Bool { + get { featureEnabled } + set { setFeatureEnabled(newValue) } + } + + var isAutosaveEnabledSetting: Bool { + get { autosaveEnabled } + set { setAutosaveEnabled(newValue) } + } + + subscript(isModuleEnabled module: String) -> Bool { + get { moduleStatuses.first { $0.module == module }?.isEnabled ?? false } + set { setModule(module, enabled: newValue) } + } +} diff --git a/LookInside/Settings/SettingsPages.swift b/LookInside/Settings/SettingsPages.swift new file mode 100644 index 00000000..8b1f609e --- /dev/null +++ b/LookInside/Settings/SettingsPages.swift @@ -0,0 +1,49 @@ +// +// SettingsPages.swift +// LookInside +// +// The pages of the Settings window, which is UIFoundation's +// SettingsWindowController (NavigationManager holds it). The window only +// changes how the settings are shown: PreferenceManager keeps the general +// ones in the standard user defaults, PrivateDiscriminatorStore keeps its +// own in config.json. +// +// The pages are laid out the way RuntimeViewer lays out its settings with +// the same window: plain glyphs in the sidebar, a header over each group, +// and what a setting does in the footer under its group. +// + +import SwiftUI +import UIFoundationSettingsUI + +enum SettingsPages { + /// Page identifiers. Unlike the titles they do not change with the + /// language, so they are what selects a page. + static let generalIdentifier = "general" + static let privateDiscriminatorIdentifier = "privateDiscriminator" + + /// The sidebar, in order. + @MainActor + @SettingsPageBuilder + static func make() -> [SettingsPage] { + // Made once per window, so the page keeps one model however often + // SwiftUI rebuilds it. + let generalModel = GeneralSettingsModel() + + SettingsPage( + String(localized: "General", comment: "Title of the Settings window's General page."), + id: generalIdentifier, + plainSymbol: "gearshape" + ) { + GeneralSettingsPage(model: generalModel) + } + + SettingsPage( + String(localized: "Private Discriminator", comment: "Title of the Settings window's Private Discriminator page."), + id: privateDiscriminatorIdentifier, + plainSymbol: "number" + ) { + PrivateDiscriminatorSettingsPage() + } + } +} diff --git a/LookInside/Toolbar/MenuPopoverSettingController.swift b/LookInside/Toolbar/MenuPopoverSettingController.swift index cf38032b..632203eb 100644 --- a/LookInside/Toolbar/MenuPopoverSettingController.swift +++ b/LookInside/Toolbar/MenuPopoverSettingController.swift @@ -133,7 +133,7 @@ final class MenuPopoverSettingController: BaseViewController { } @objc private func handlePreferenceButton() { - NavigationManager.shared.showPreference() + NavigationManager.shared.showSettings() } @objc private func handleZInterspaceDidChange(_ param: MessageActionParameters) { diff --git a/Packages/LookInsideHostCore/Sources/LookInsideHostCore/PreferenceValuePolicy.swift b/Packages/LookInsideHostCore/Sources/LookInsideHostCore/PreferenceValuePolicy.swift new file mode 100644 index 00000000..11e6ea51 --- /dev/null +++ b/Packages/LookInsideHostCore/Sources/LookInsideHostCore/PreferenceValuePolicy.swift @@ -0,0 +1,31 @@ +// +// PreferenceValuePolicy.swift +// LookInsideHostCore +// +// The ranges the Settings window keeps user-entered preference values in, +// before the app target writes them to its PreferenceManager. +// + +/// What the Settings window stores for a value the user entered. +public enum PreferenceValuePolicy { + /// Request timeouts, in seconds. + public static let timeoutRange: ClosedRange = 0.1 ... 120 + + /// Image contrast levels: normal, medium and high. + public static let imageContrastLevelRange: ClosedRange = 0 ... 2 + + /// A typed timeout moved into `timeoutRange`. A value that is not + /// finite, or not above 0, means nothing usable was entered, so it + /// falls back to `defaultValue` instead. + public static func sanitizedTimeout(_ value: Double, defaultValue: Double) -> Double { + guard value.isFinite, value > 0 else { + return defaultValue + } + return min(max(value, timeoutRange.lowerBound), timeoutRange.upperBound) + } + + /// A contrast level moved into `imageContrastLevelRange`. + public static func clampedImageContrastLevel(_ level: Int) -> Int { + min(max(level, imageContrastLevelRange.lowerBound), imageContrastLevelRange.upperBound) + } +} diff --git a/Packages/LookInsideHostCore/Tests/LookInsideHostCoreTests/PreferenceValuePolicyTests.swift b/Packages/LookInsideHostCore/Tests/LookInsideHostCoreTests/PreferenceValuePolicyTests.swift new file mode 100644 index 00000000..1920e8da --- /dev/null +++ b/Packages/LookInsideHostCore/Tests/LookInsideHostCoreTests/PreferenceValuePolicyTests.swift @@ -0,0 +1,28 @@ +@testable import LookInsideHostCore +import Testing + +struct PreferenceValuePolicyTests { + @Test(arguments: [ + (typed: 15.0, stored: 15.0), + (typed: 0.05, stored: 0.1), + (typed: 500, stored: 120), + (typed: 0, stored: 7), + (typed: -3, stored: 7), + (typed: .nan, stored: 7), + (typed: .infinity, stored: 7), + ]) + func aTypedTimeoutIsKeptBetweenATenthOfASecondAndTwoMinutes(typed: Double, stored: Double) { + #expect(PreferenceValuePolicy.sanitizedTimeout(typed, defaultValue: 7) == stored) + } + + @Test(arguments: [ + (chosen: -1, stored: 0), + (chosen: 0, stored: 0), + (chosen: 1, stored: 1), + (chosen: 2, stored: 2), + (chosen: 5, stored: 2), + ]) + func theImageContrastLevelStaysBetweenNormalAndHigh(chosen: Int, stored: Int) { + #expect(PreferenceValuePolicy.clampedImageContrastLevel(chosen) == stored) + } +}