From 24d9a538584bae0163f27c9215c8453f965ad47a Mon Sep 17 00:00:00 2001 From: Qijia Liu Date: Wed, 4 Nov 2026 14:47:03 -0500 Subject: [PATCH] Add English and Simplified Chinese localization --- .github/workflows/build.yml | 4 + AGENTS.md | 1 + assets/Localizable.xcstrings | 754 +++++++++++++++++++++++++++++++++ cmake/GenerateStrings.cmake | 15 + cmake/Localization.cmake | 57 +++ src/CMakeLists.txt | 2 + src/ContentView.swift | 15 +- src/MissingXcodeView.swift | 5 +- src/NoSimulatorsView.swift | 5 +- src/SidebarView.swift | 28 +- src/SimStoreApp.swift | 2 +- src/SimulatorDiscovery.swift | 10 +- src/SimulatorStatusViews.swift | 7 +- tests/CMakeLists.txt | 6 + tests/LocalizationTests.swift | 88 ++++ 15 files changed, 977 insertions(+), 22 deletions(-) create mode 100644 assets/Localizable.xcstrings create mode 100644 cmake/GenerateStrings.cmake create mode 100644 cmake/Localization.cmake create mode 100644 tests/LocalizationTests.swift diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ac5219d..7834586 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -36,6 +36,10 @@ jobs: if-no-files-found: error - name: Check Swift formatting run: xcrun swift-format lint --recursive --strict src tests + - name: Check localization catalog + run: | + cmake --build --preset release --target GenerateStrings + git diff --exit-code -- assets/Localizable.xcstrings - name: Test run: ctest --preset release --verbose diff --git a/AGENTS.md b/AGENTS.md index 90f3e09..23e21c0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,4 +7,5 @@ - Keep generated files and build output under build/. Do not hardcode personal machine paths. - Write documentation in English. Keep each prose paragraph on one physical line. Keep the README focused on the project's purpose. Keep temporary progress reports and conversation history out of repository documentation. - Keep product UI focused on user tasks. Do not show mock catalog entries, simulated install results, or implementation details as real functionality. +- Use SwiftUI localized literals and `String(localized:)` for user-visible strings. After changing them, run `cmake --build --preset release --target GenerateStrings`, update the English and Simplified Chinese translations in `assets/Localizable.xcstrings`, and rebuild. Keep localization files in UTF-8. - Run appropriate checks and an actual build when changing build logic. Verify launch and window behavior when changing app startup. Report build, launch, and functional verification separately. diff --git a/assets/Localizable.xcstrings b/assets/Localizable.xcstrings new file mode 100644 index 0000000..ca2812e --- /dev/null +++ b/assets/Localizable.xcstrings @@ -0,0 +1,754 @@ +{ + "sourceLanguage": "en", + "strings": { + "%@ couldn’t be opened. %@": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ couldn’t be opened. %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法打开 %@。%@" + } + } + } + }, + "%@ couldn’t be opened. Make sure the full Xcode application is installed and selected.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ couldn’t be opened. Make sure the full Xcode application is installed and selected." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法打开 %@。请确认已安装并选中完整的 Xcode 应用。" + } + } + } + }, + "%@ exited with status %d.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ exited with status %d." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ 已退出,退出码为 %d。" + } + } + } + }, + "A simulator is where it starts.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "A simulator is where it starts." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "从一台模拟器开始。" + } + } + } + }, + "App Repository": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "App Repository" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "应用仓库" + } + } + } + }, + "Booted": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Booted" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已启动" + } + } + } + }, + "Booting": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Booting" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在启动" + } + } + } + }, + "Check Again": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Check Again" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重新检查" + } + } + } + }, + "Check for Simulators": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Check for Simulators" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "检查模拟器" + } + } + } + }, + "Check for iOS simulators": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Check for iOS simulators" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "检查可用的 iOS 模拟器" + } + } + } + }, + "Checking for devices…": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Checking for devices…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在检查设备…" + } + } + } + }, + "Checking…": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Checking…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在检查…" + } + } + } + }, + "Couldn’t Open Application": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Couldn’t Open Application" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法打开应用" + } + } + } + }, + "Couldn’t check for simulators": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Couldn’t check for simulators" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法检查模拟器" + } + } + } + }, + "Create an iPhone or iPad simulator in %@.\nThen come back to find your next favorite app.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Create an iPhone or iPad simulator in %@.\nThen come back to find your next favorite app." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在 %@ 中创建 iPhone 或 iPad 模拟器。\n然后回来发现你喜爱的应用。" + } + } + } + }, + "Details": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Details" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "详情" + } + } + } + }, + "Device Hub or Simulator couldn’t be found in the selected Xcode installation.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Device Hub or Simulator couldn’t be found in the selected Xcode installation." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在所选的 Xcode 中找不到 Device Hub 或 Simulator。" + } + } + } + }, + "Discover": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Discover" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "发现" + } + } + } + }, + "Found %lld available iOS simulators.": { + "localizations": { + "en": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "Found %lld available iOS simulator." + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "Found %lld available iOS simulators." + } + } + } + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "找到 %lld 台可用的 iOS 模拟器。" + } + } + } + }, + "Install Xcode to add iPhone and iPad simulators.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Install Xcode to add iPhone and iPad simulators." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "安装 Xcode 以添加 iPhone 和 iPad 模拟器。" + } + } + } + }, + "Installed": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Installed" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已安装" + } + } + } + }, + "Looking for your simulators…": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Looking for your simulators…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在查找模拟器…" + } + } + } + }, + "Make sure Xcode is installed and its command-line tools are selected, then try again.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Make sure Xcode is installed and its command-line tools are selected, then try again." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请确认已安装 Xcode 并选中其命令行工具,然后重试。" + } + } + } + }, + "No devices to display": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No devices to display" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "暂无设备" + } + } + } + }, + "No simulator selected": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No simulator selected" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未选择模拟器" + } + } + } + }, + "No simulators yet": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No simulators yet" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "暂无模拟器" + } + } + } + }, + "OK": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "好" + } + } + } + }, + "Open %@": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "打开 %@" + } + } + } + }, + "Open %@ in your web browser.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open %@ in your web browser." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请在浏览器中打开 %@。" + } + } + } + }, + "Open App Store": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open App Store" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "打开 App Store" + } + } + } + }, + "Open Xcode": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Xcode" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "打开 Xcode" + } + } + } + }, + "Open the App Store and search for Xcode.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open the App Store and search for Xcode." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请打开 App Store 并搜索 Xcode。" + } + } + } + }, + "Open-source favorites. Built for your simulator.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open-source favorites. Built for your simulator." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "你喜爱的开源应用,专为模拟器构建。" + } + } + } + }, + "SIMULATORS": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "SIMULATORS" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "模拟器" + } + } + } + }, + "Shutdown": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Shutdown" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已关机" + } + } + } + }, + "Shutting Down": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Shutting Down" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在关机" + } + } + } + }, + "Start with Xcode.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Start with Xcode." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "从 Xcode 开始。" + } + } + } + }, + "Try Again": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Try Again" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重试" + } + } + } + }, + "Updates": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Updates" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "更新" + } + } + } + }, + "Xcode did not provide a valid developer directory.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Xcode did not provide a valid developer directory." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Xcode 未提供有效的开发者目录。" + } + } + } + }, + "Xcode is not installed.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Xcode is not installed." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "尚未安装 Xcode。" + } + } + } + }, + "Xcode provides the tools SimStore needs to run iOS apps.\nInstall it from the App Store, then open it to finish setup.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Xcode provides the tools SimStore needs to run iOS apps.\nInstall it from the App Store, then open it to finish setup." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Xcode 提供 SimStore 运行 iOS 应用所需的工具。\n请从 App Store 安装,然后打开 Xcode 完成设置。" + } + } + } + }, + "Xcode required": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Xcode required" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "需要 Xcode" + } + } + } + }, + "Your iPhone and iPad simulators will appear here.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your iPhone and iPad simulators will appear here." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "iPhone 和 iPad 模拟器将显示在这里。" + } + } + } + }, + "Your simulators are ready": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your simulators are ready" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "模拟器已就绪" + } + } + } + }, + "iOS %@ · %@": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "iOS %@ · %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "iOS %@ · %@" + } + } + } + } + }, + "version": "1.0" +} diff --git a/cmake/GenerateStrings.cmake b/cmake/GenerateStrings.cmake new file mode 100644 index 0000000..214cb4e --- /dev/null +++ b/cmake/GenerateStrings.cmake @@ -0,0 +1,15 @@ +set(AVAILABLE_STRINGSDATA_FILES) +foreach(STRINGSDATA_FILE IN LISTS STRINGSDATA_FILES) + if(EXISTS "${STRINGSDATA_FILE}") + list(APPEND AVAILABLE_STRINGSDATA_FILES "${STRINGSDATA_FILE}") + endif() +endforeach() +if(NOT AVAILABLE_STRINGSDATA_FILES) + message(FATAL_ERROR "No Swift localization metadata was generated.") +endif() + +execute_process( + COMMAND "${XCSTRINGSTOOL_EXECUTABLE}" sync "${LOCALIZATION_CATALOG}" + --stringsdata ${AVAILABLE_STRINGSDATA_FILES} + COMMAND_ERROR_IS_FATAL ANY +) diff --git a/cmake/Localization.cmake b/cmake/Localization.cmake new file mode 100644 index 0000000..12fa110 --- /dev/null +++ b/cmake/Localization.cmake @@ -0,0 +1,57 @@ +execute_process(COMMAND xcrun --find xcstringstool + OUTPUT_VARIABLE XCSTRINGSTOOL_EXECUTABLE + OUTPUT_STRIP_TRAILING_WHITESPACE + COMMAND_ERROR_IS_FATAL ANY +) + +set(LOCALIZATION_CATALOG "${PROJECT_SOURCE_DIR}/assets/Localizable.xcstrings") +set(LOCALIZATION_OUTPUT_DIR "${CMAKE_CURRENT_BINARY_DIR}/localization") +set(STRINGSDATA_DIR "${CMAKE_CURRENT_BINARY_DIR}/stringsdata") +file(MAKE_DIRECTORY "${STRINGSDATA_DIR}") + +target_compile_options(SimStore PRIVATE + -emit-localized-strings + -emit-localized-strings-path "${STRINGSDATA_DIR}" +) + +# Use only current sources so metadata from renamed or removed files is ignored. +get_target_property(LOCALIZABLE_SOURCES SimStore SOURCES) +list(FILTER LOCALIZABLE_SOURCES INCLUDE REGEX "\\.swift$") +set(LOCALIZATION_STRINGSDATA_FILES) +foreach(SOURCE IN LISTS LOCALIZABLE_SOURCES) + get_filename_component(SOURCE_NAME "${SOURCE}" NAME_WE) + list(APPEND LOCALIZATION_STRINGSDATA_FILES "${STRINGSDATA_DIR}/${SOURCE_NAME}.stringsdata") +endforeach() + +set(LOCALIZATION_RESOURCES + "${LOCALIZATION_OUTPUT_DIR}/en.lproj/Localizable.strings" + "${LOCALIZATION_OUTPUT_DIR}/en.lproj/Localizable.stringsdict" + "${LOCALIZATION_OUTPUT_DIR}/zh-Hans.lproj/Localizable.strings" +) +add_custom_command(OUTPUT ${LOCALIZATION_RESOURCES} + COMMAND "${CMAKE_COMMAND}" -E make_directory "${LOCALIZATION_OUTPUT_DIR}" + COMMAND "${XCSTRINGSTOOL_EXECUTABLE}" compile "${LOCALIZATION_CATALOG}" + --output-directory "${LOCALIZATION_OUTPUT_DIR}" --language en --language zh-Hans + DEPENDS "${LOCALIZATION_CATALOG}" + COMMENT "Compiling English and Simplified Chinese strings" + VERBATIM +) +foreach(RESOURCE IN LISTS LOCALIZATION_RESOURCES) + get_filename_component(RESOURCE_DIRECTORY "${RESOURCE}" DIRECTORY) + get_filename_component(LOCALE_DIRECTORY "${RESOURCE_DIRECTORY}" NAME) + set_source_files_properties("${RESOURCE}" PROPERTIES + MACOSX_PACKAGE_LOCATION "Resources/${LOCALE_DIRECTORY}" + ) +endforeach() +target_sources(SimStore PRIVATE ${LOCALIZATION_RESOURCES}) + +add_custom_target(GenerateStrings + COMMAND "${CMAKE_COMMAND}" + "-DXCSTRINGSTOOL_EXECUTABLE=${XCSTRINGSTOOL_EXECUTABLE}" + "-DLOCALIZATION_CATALOG=${LOCALIZATION_CATALOG}" + "-DSTRINGSDATA_FILES=${LOCALIZATION_STRINGSDATA_FILES}" + -P "${PROJECT_SOURCE_DIR}/cmake/GenerateStrings.cmake" + DEPENDS SimStore + COMMENT "Updating the string catalog from Swift sources" + VERBATIM +) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 5f79f15..2d1e7bd 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -10,6 +10,8 @@ add_executable(SimStore MACOSX_BUNDLE SimulatorStatusViews.swift ) +include("${PROJECT_SOURCE_DIR}/cmake/Localization.cmake") + find_program(RSVG_CONVERT_EXECUTABLE rsvg-convert REQUIRED) find_program(ICONUTIL_EXECUTABLE iconutil REQUIRED) set(ICONSET_DIR "${CMAKE_CURRENT_BINARY_DIR}/simstore.iconset") diff --git a/src/ContentView.swift b/src/ContentView.swift index e2a172b..019d699 100644 --- a/src/ContentView.swift +++ b/src/ContentView.swift @@ -79,7 +79,7 @@ struct ContentView: View { } .disabled(isChecking) .keyboardShortcut("r", modifiers: .command) - .help("Check for iOS simulators") + .help(Text("Check for iOS simulators")) } } } @@ -145,13 +145,13 @@ struct ContentView: View { private func openRepository() { let url = AppConfiguration.buildsRepositoryURL if !NSWorkspace.shared.open(url) { - launchError = "Open \(url.absoluteString) in your web browser." + launchError = String(localized: "Open \(url.absoluteString) in your web browser.") } } private func openAppStore() { if !NSWorkspace.shared.open(URL(string: "macappstore://apps.apple.com/app/id497799835")!) { - launchError = "Open the App Store and search for Xcode." + launchError = String(localized: "Open the App Store and search for Xcode.") } } @@ -169,15 +169,18 @@ struct ContentView: View { private func openApplication(_ url: URL?, name: String) { guard let url, FileManager.default.fileExists(atPath: url.path) else { - launchError = - "\(name) couldn’t be opened. Make sure the full Xcode application is installed and selected." + launchError = String( + localized: + "\(name) couldn’t be opened. Make sure the full Xcode application is installed and selected." + ) return } NSWorkspace.shared.openApplication(at: url, configuration: NSWorkspace.OpenConfiguration()) { _, error in if let error { Task { @MainActor in - launchError = "\(name) couldn’t be opened. \(error.localizedDescription)" + launchError = String( + localized: "\(name) couldn’t be opened. \(error.localizedDescription)") } } } diff --git a/src/MissingXcodeView.swift b/src/MissingXcodeView.swift index ce77d6a..2130a6b 100644 --- a/src/MissingXcodeView.swift +++ b/src/MissingXcodeView.swift @@ -28,7 +28,10 @@ struct MissingXcodeView: View { HStack(spacing: 10) { Button("Open App Store", systemImage: "bag", action: openAppStore) .buttonStyle(.borderedProminent) - Button(isChecking ? "Checking…" : "Check Again", systemImage: "arrow.clockwise") { + Button( + isChecking ? String(localized: "Checking…") : String(localized: "Check Again"), + systemImage: "arrow.clockwise" + ) { checkAgain() } .buttonStyle(.bordered) diff --git a/src/NoSimulatorsView.swift b/src/NoSimulatorsView.swift index 71dfbff..d58f25a 100644 --- a/src/NoSimulatorsView.swift +++ b/src/NoSimulatorsView.swift @@ -52,7 +52,10 @@ struct NoSimulatorsView: View { openDeviceManager() } .buttonStyle(.borderedProminent) - Button(isChecking ? "Checking…" : "Check Again", systemImage: "arrow.clockwise") { + Button( + isChecking ? String(localized: "Checking…") : String(localized: "Check Again"), + systemImage: "arrow.clockwise" + ) { checkAgain() } .buttonStyle(.bordered) diff --git a/src/SidebarView.swift b/src/SidebarView.swift index 41e7ceb..5b7e324 100644 --- a/src/SidebarView.swift +++ b/src/SidebarView.swift @@ -14,7 +14,7 @@ struct SidebarView: View { Image(nsImage: NSApplication.shared.applicationIconImage) .resizable() .frame(width: 37, height: 37) - Text("SimStore") + Text(verbatim: "SimStore") .font(.system(size: 22, weight: .bold)) .tracking(-0.7) } @@ -54,7 +54,7 @@ struct SidebarView: View { Label { VStack(alignment: .leading, spacing: 4) { Text(device.name).font(.system(size: 12, weight: .medium)) - Text("iOS \(device.runtime) · \(device.state)") + Text("iOS \(device.runtime) · \(stateLabel(device.state))") .font(.system(size: 10)) .foregroundStyle(.secondary) } @@ -75,16 +75,18 @@ struct SidebarView: View { VStack(alignment: .leading, spacing: 7) { Text( isChecking - ? "Checking for devices…" + ? String(localized: "Checking for devices…") : needsXcode - ? "Xcode required" - : discovery == nil ? "No devices to display" : "No simulators yet" + ? String(localized: "Xcode required") + : discovery == nil + ? String(localized: "No devices to display") + : String(localized: "No simulators yet") ) .font(.system(size: 12, weight: .medium)) Text( needsXcode - ? "Install Xcode to add iPhone and iPad simulators." - : "Your iPhone and iPad simulators will appear here." + ? String(localized: "Install Xcode to add iPhone and iPad simulators.") + : String(localized: "Your iPhone and iPad simulators will appear here.") ) .font(.system(size: 11)) .foregroundStyle(.secondary) @@ -109,7 +111,17 @@ struct SidebarView: View { } } - private func sidebarPlaceholder(_ title: String, systemImage: String) -> some View { + private func stateLabel(_ state: String) -> String { + switch state { + case "Booted": String(localized: "Booted") + case "Shutdown": String(localized: "Shutdown") + case "Booting": String(localized: "Booting") + case "Shutting Down": String(localized: "Shutting Down") + default: state + } + } + + private func sidebarPlaceholder(_ title: LocalizedStringKey, systemImage: String) -> some View { Label(title, systemImage: systemImage) .font(.system(size: 13)) .foregroundStyle(.tertiary) diff --git a/src/SimStoreApp.swift b/src/SimStoreApp.swift index 9aa72cd..dc278b9 100644 --- a/src/SimStoreApp.swift +++ b/src/SimStoreApp.swift @@ -5,7 +5,7 @@ struct SimStoreApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate var body: some Scene { - Window("SimStore", id: "main") { + Window(Text(verbatim: "SimStore"), id: "main") { ContentView() } .defaultSize(width: 1180, height: 780) diff --git a/src/SimulatorDiscovery.swift b/src/SimulatorDiscovery.swift index 6c71383..21a4893 100644 --- a/src/SimulatorDiscovery.swift +++ b/src/SimulatorDiscovery.swift @@ -3,7 +3,7 @@ import Foundation enum SimulatorDiscoveryError: LocalizedError, Equatable { case xcodeNotInstalled - var errorDescription: String? { "Xcode is not installed." } + var errorDescription: String? { String(localized: "Xcode is not installed.") } } struct SimulatorDevice: Identifiable, Equatable, Sendable { @@ -53,7 +53,8 @@ struct SimulatorDiscovery: Sendable { throw NSError( domain: "SimulatorDiscovery", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "Xcode did not provide a valid developer directory." + NSLocalizedDescriptionKey: String( + localized: "Xcode did not provide a valid developer directory.") ]) } let developerDirectory = URL(fileURLWithPath: directory) @@ -118,7 +119,10 @@ struct SimulatorDiscovery: Sendable { domain: "SimulatorDiscovery", code: Int(process.terminationStatus), userInfo: [ NSLocalizedDescriptionKey: diagnostic.isEmpty - ? "\(process.executableURL!.lastPathComponent) exited with status \(process.terminationStatus)." + ? String( + localized: + "\(process.executableURL!.lastPathComponent) exited with status \(process.terminationStatus)." + ) : diagnostic ]) } diff --git a/src/SimulatorStatusViews.swift b/src/SimulatorStatusViews.swift index d611b1d..eb3c342 100644 --- a/src/SimulatorStatusViews.swift +++ b/src/SimulatorStatusViews.swift @@ -24,7 +24,7 @@ struct SimulatorsReadyView: View { Text("Your simulators are ready") .font(.system(size: 24, weight: .semibold)) Text( - "Found \(count) available iOS \(count == 1 ? "simulator" : "simulators")." + "Found \(count) available iOS simulators." ) .font(.system(size: 13)) .foregroundStyle(.secondary) @@ -55,7 +55,10 @@ struct SimulatorDiscoveryErrorView: View { .frame(maxWidth: 390) HStack(spacing: 10) { Button("Open Xcode", systemImage: "hammer") { openXcode() } - Button(isChecking ? "Checking…" : "Try Again", systemImage: "arrow.clockwise") { + Button( + isChecking ? String(localized: "Checking…") : String(localized: "Try Again"), + systemImage: "arrow.clockwise" + ) { checkAgain() } .disabled(isChecking) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index de04bf6..6b7f92b 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -13,6 +13,12 @@ add_executable(SimStoreDiscoveryTest SimulatorDiscoveryTests.swift "${PROJECT_SO add_test(NAME simulator-discovery COMMAND SimStoreDiscoveryTest) set_tests_properties(simulator-discovery PROPERTIES TIMEOUT 15) +add_executable(SimStoreLocalizationTest LocalizationTests.swift) +add_dependencies(SimStoreLocalizationTest SimStore) +target_compile_options(SimStoreLocalizationTest PRIVATE -parse-as-library) +add_test(NAME localization COMMAND SimStoreLocalizationTest "$") +set_tests_properties(localization PROPERTIES TIMEOUT 10) + execute_process(COMMAND xcrun --sdk "${CMAKE_OSX_SYSROOT}" --show-sdk-version OUTPUT_VARIABLE SDK_VERSION OUTPUT_STRIP_TRAILING_WHITESPACE diff --git a/tests/LocalizationTests.swift b/tests/LocalizationTests.swift new file mode 100644 index 0000000..1cee270 --- /dev/null +++ b/tests/LocalizationTests.swift @@ -0,0 +1,88 @@ +import Foundation + +@main +struct LocalizationTests { + static func main() { + do { + try verifyTranslations() + print("PASS: English and Simplified Chinese resources, interpolation, and plurals.") + } catch { + FileHandle.standardError.write( + Data("Localization test failed: \(error.localizedDescription)\n".utf8)) + exit(EXIT_FAILURE) + } + } + + static func verifyTranslations() throws { + guard CommandLine.arguments.count == 2, + let app = Bundle(url: URL(fileURLWithPath: CommandLine.arguments[1])) + else { throw failure("Expected the built App Bundle path.") } + try require(Set(app.localizations) == ["en", "zh-Hans"], "Unexpected supported languages.") + try require(app.developmentLocalization == "en", "English must be the fallback language.") + + for language in ["en", "zh-Hans"] { + guard let directory = app.url(forResource: language, withExtension: "lproj"), + let bundle = Bundle(url: directory) + else { throw failure("Missing \(language) localization resources.") } + for file in try FileManager.default.contentsOfDirectory( + at: directory, includingPropertiesForKeys: nil) + where ["strings", "stringsdict"].contains(file.pathExtension) { + _ = try String(contentsOf: file, encoding: .utf8) + } + let locale = Locale(identifier: language) + let english = language == "en" + let labels: [(String.LocalizationValue, String, String)] = [ + ("Discover", "Discover", "发现"), ("Installed", "Installed", "已安装"), + ("Updates", "Updates", "更新"), + ] + for (key, source, translated) in labels { + try expect( + String(localized: key, bundle: bundle, locale: locale), english ? source : translated) + } + + let tool = "Device Hub" + try expect( + String(localized: "Open \(tool)", bundle: bundle, locale: locale), + english ? "Open Device Hub" : "打开 Device Hub") + let url = "https://example.com/releases/download/app%2F1.0.0/app.tar.bz2" + try expect( + String(localized: "Open \(url) in your web browser.", bundle: bundle, locale: locale), + english ? "Open \(url) in your web browser." : "请在浏览器中打开 \(url)。") + let detail = "Permission denied (13)." + try expect( + String(localized: "\(tool) couldn’t be opened. \(detail)", bundle: bundle, locale: locale), + english ? "Device Hub couldn’t be opened. \(detail)" : "无法打开 Device Hub。\(detail)") + try expect( + String( + localized: + "Create an iPhone or iPad simulator in \(tool).\nThen come back to find your next favorite app.", + bundle: bundle, locale: locale), + english + ? "Create an iPhone or iPad simulator in Device Hub.\nThen come back to find your next favorite app." + : "在 Device Hub 中创建 iPhone 或 iPad 模拟器。\n然后回来发现你喜爱的应用。") + + for count in [0, 1, 2, 40] { + let expected = + english + ? "Found \(count) available iOS \(count == 1 ? "simulator" : "simulators")." + : "找到 \(count) 台可用的 iOS 模拟器。" + try expect( + String( + localized: "Found \(count) available iOS simulators.", bundle: bundle, locale: locale), + expected) + } + } + } + + static func expect(_ actual: String, _ expected: String) throws { + try require(actual == expected, "Expected '\(expected)', received '\(actual)'.") + } + + static func require(_ condition: Bool, _ message: String) throws { + if !condition { throw failure(message) } + } + + static func failure(_ message: String) -> NSError { + NSError(domain: "LocalizationTests", code: 1, userInfo: [NSLocalizedDescriptionKey: message]) + } +}