diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj index c809b4458..7f22ded5b 100644 --- a/HomeAssistant.xcodeproj/project.pbxproj +++ b/HomeAssistant.xcodeproj/project.pbxproj @@ -1524,6 +1524,7 @@ C10D762EFE08D347D0538339 /* Pods-iOS-Shared-iOS-Tests-Shared-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = B2F5238669D8A7416FBD2B55 /* Pods-iOS-Shared-iOS-Tests-Shared-metadata.plist */; }; C35621B95F7E4548BC8F6D75 /* FolderEditView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BECEB2525564358A124F818 /* FolderEditView.swift */; }; C574CE3276BCE901743FF8C9 /* KioskSettings.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFD4B475DDA9447E45A9BAD3 /* KioskSettings.test.swift */; }; + DEFBE1A5E9A005B0A5392D27 /* KioskLocalization.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F4593A60DBF019E6C91AAA7 /* KioskLocalization.test.swift */; }; C8860D27D848451A887BC441 /* WatchFolderRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DA2D62699FC44A99AB37480 /* WatchFolderRow.swift */; }; CA6886D02384DA18A91F37DD /* Pods-iOS-Extensions-Intents-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = E41A4AAEF642A72ACDB6C006 /* Pods-iOS-Extensions-Intents-metadata.plist */; }; CB4D44CC6DBA5176155E157E /* KioskSecretExitGestureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCE1C6F8FA2181C936758465 /* KioskSecretExitGestureView.swift */; }; @@ -3471,6 +3472,7 @@ E41A4AAEF642A72ACDB6C006 /* Pods-iOS-Extensions-Intents-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-iOS-Extensions-Intents-metadata.plist"; path = "Pods/Pods-iOS-Extensions-Intents-metadata.plist"; sourceTree = ""; }; ED4B2D38DF1316D881D79769 /* Pods-iOS-Shared-iOS.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Shared-iOS.debug.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Shared-iOS/Pods-iOS-Shared-iOS.debug.xcconfig"; sourceTree = ""; }; EF91E383A44843F087423FB5 /* WidgetCommonlyUsedEntitiesTimelineProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetCommonlyUsedEntitiesTimelineProvider.swift; sourceTree = ""; }; + 4F4593A60DBF019E6C91AAA7 /* KioskLocalization.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KioskLocalization.test.swift; sourceTree = ""; }; EFD4B475DDA9447E45A9BAD3 /* KioskSettings.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KioskSettings.test.swift; sourceTree = ""; }; F49767602CA2066683EC638F /* KioskSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KioskSettingsView.swift; sourceTree = ""; }; F6DA82FEEE2DDC3B2CC20DA3 /* Pods_iOS_Extensions_NotificationService.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iOS_Extensions_NotificationService.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -3676,6 +3678,7 @@ 06D62F8A8D381DAFB70C6B31 /* Kiosk */ = { isa = PBXGroup; children = ( + 4F4593A60DBF019E6C91AAA7 /* KioskLocalization.test.swift */, EFD4B475DDA9447E45A9BAD3 /* KioskSettings.test.swift */, ); path = Kiosk; @@ -9699,6 +9702,7 @@ 429481EB2DA93FA000A8B468 /* WebViewJavascriptCommandsTests.swift in Sources */, 119C786725CF845800D41734 /* LocalizedStrings.test.swift in Sources */, C574CE3276BCE901743FF8C9 /* KioskSettings.test.swift in Sources */, + DEFBE1A5E9A005B0A5392D27 /* KioskLocalization.test.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Sources/App/Kiosk/Settings/KioskSettingsView.swift b/Sources/App/Kiosk/Settings/KioskSettingsView.swift index f04056b6c..c72942a27 100644 --- a/Sources/App/Kiosk/Settings/KioskSettingsView.swift +++ b/Sources/App/Kiosk/Settings/KioskSettingsView.swift @@ -168,7 +168,7 @@ public struct KioskSettingsView: View { Text( L10n.Kiosk.Security.gestureFooter( viewModel.settings.secretExitGestureCorner.displayName, - viewModel.settings.secretExitGestureTaps + String(viewModel.settings.secretExitGestureTaps) ) ) } diff --git a/Tests/App/Kiosk/KioskLocalization.test.swift b/Tests/App/Kiosk/KioskLocalization.test.swift new file mode 100644 index 000000000..f80c8651c --- /dev/null +++ b/Tests/App/Kiosk/KioskLocalization.test.swift @@ -0,0 +1,146 @@ +import Foundation +@testable import HomeAssistant +@testable import Shared +import XCTest + +/// Regression tests for kiosk L10n format strings across all bundled locales. +/// +/// Guards against issue #4487, in which German and Dutch translations of +/// `kiosk.security.gesture_footer` reordered format specifiers without using +/// positional markers (`%N$...`), causing `String(format:)` to misinterpret a +/// CVarArg as the wrong type and crash (EXC_BAD_ACCESS) when the Kiosk +/// settings view body was evaluated. +/// +/// These tests exercise every kiosk format-string key against every bundled +/// `*.lproj/Localizable.strings` file, confirming the format call completes +/// without crashing and returns non-empty output containing the supplied +/// argument values. +final class KioskLocalizationTests: XCTestCase { + /// Kiosk format-string keys that take at least one argument, paired with + /// representative invocation args. Args are strings because the current + /// SwiftGen output coerces all args with `String(describing:)` before + /// passing to `String(format:)` — this test mirrors the runtime path. + private static let kioskFormatKeys: [(key: String, args: [CVarArg], specifiers: Int)] = [ + ("kiosk.brightness.manual", [80 as Int], 1), + ("kiosk.screensaver.dim_level", [25 as Int], 1), + ("kiosk.clock.accessibility.analog_clock", ["3:45 PM"], 1), + ("kiosk.clock.accessibility.current_time", ["3:45 PM"], 1), + ("kiosk.clock.accessibility.date", ["Wednesday, April 8"], 1), + ("kiosk.security.taps_required", [5 as Int], 1), + ("kiosk.security.gesture_footer", ["top-left", "5"], 2), + ] + + func testKioskFormatStringsAcrossAllLocales() throws { + // Matches a single printf-style format specifier, e.g. `%@`, `%li`, `%1$@`. + let specifierRegex = try NSRegularExpression( + pattern: "%{1,2}[+0123456789$.luq]*?[sduiefgcCp@]" + ) + let bundle = Bundle(for: AppDelegate.self) + + let lprojURLs: [URL] = try { + let resourceURL = try XCTUnwrap(bundle.resourceURL) + let enumerator = try XCTUnwrap(FileManager.default.enumerator( + at: resourceURL, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsSubdirectoryDescendants] + )) + return enumerator.compactMap { $0 as? URL } + .filter { $0.pathExtension == "lproj" } + .filter { $0.deletingPathExtension().lastPathComponent != "Base" } + }() + + XCTAssertGreaterThan(lprojURLs.count, 1, "Expected multiple bundled locales") + + for lprojURL in lprojURLs { + let language = lprojURL.deletingPathExtension().lastPathComponent + let stringsURL = lprojURL.appendingPathComponent("Localizable.strings") + guard let strings = NSDictionary(contentsOf: stringsURL) as? [String: String] else { + XCTFail("Could not load Localizable.strings for locale \(language)") + continue + } + + for (key, args, expectedSpecifiers) in Self.kioskFormatKeys { + guard let format = strings[key] else { + // Missing key is acceptable (fallback to English via LocalizedManager) + continue + } + + let specifierCount = specifierRegex.numberOfMatches( + in: format, + range: NSRange(location: 0, length: format.utf16.count) + ) + XCTAssertEqual( + specifierCount, + expectedSpecifiers, + "Locale '\(language)' key '\(key)' has \(specifierCount) format specifiers, expected \(expectedSpecifiers): \(format)" + ) + + // Execute the format call — this is the path that crashed in #4487. + let result = String(format: format, locale: Locale(identifier: language), arguments: args) + XCTAssertFalse( + result.isEmpty, + "Locale '\(language)' key '\(key)' produced empty result" + ) + // Every supplied arg must appear in the output, confirming each specifier consumed a value. + for arg in args { + let argString: String + if let s = arg as? String { + argString = s + } else if let i = arg as? Int { + argString = "\(i)" + } else { + continue + } + XCTAssertTrue( + result.contains(argString), + "Locale '\(language)' key '\(key)' output '\(result)' missing arg '\(argString)'" + ) + } + } + } + } + + /// Targeted regression test for issue #4487: exercises the real + /// `L10n.Kiosk.Security.gestureFooter` function via the app's + /// `LocalizedManager`, once per bundled locale, by injecting a string + /// provider that returns that locale's format string. + func testGestureFooterDoesNotCrashAcrossLocales() throws { + let bundle = Bundle(for: AppDelegate.self) + let resourceURL = try XCTUnwrap(bundle.resourceURL) + let enumerator = try XCTUnwrap(FileManager.default.enumerator( + at: resourceURL, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsSubdirectoryDescendants] + )) + let lprojURLs = enumerator.compactMap { $0 as? URL } + .filter { $0.pathExtension == "lproj" } + .filter { $0.deletingPathExtension().lastPathComponent != "Base" } + + let savedLocalized = Current.localized + defer { Current.localized = savedLocalized } + + for lprojURL in lprojURLs { + let language = lprojURL.deletingPathExtension().lastPathComponent + let stringsURL = lprojURL.appendingPathComponent("Localizable.strings") + guard let strings = NSDictionary(contentsOf: stringsURL) as? [String: String], + let format = strings["kiosk.security.gesture_footer"] else { + continue + } + + // Inject this locale's format into the LocalizedManager. + let localized = LocalizedManager() + localized.add(stringProvider: { request in + request.key == "kiosk.security.gesture_footer" ? format : nil + }) + Current.localized = localized + + // Call the generated L10n function — this is the exact path + // KioskSettingsView exercises when rendering the footer. + let result = L10n.Kiosk.Security.gestureFooter("top-left", String(5)) + XCTAssertFalse( + result.isEmpty, + "Locale '\(language)' gestureFooter produced empty result" + ) + } + } +}