diff --git a/Sources/MacOptimizer/App/AppState.swift b/Sources/MacOptimizer/App/AppState.swift index 85f67ee..26185d1 100644 --- a/Sources/MacOptimizer/App/AppState.swift +++ b/Sources/MacOptimizer/App/AppState.swift @@ -348,11 +348,7 @@ public final class AppState: ObservableObject { chatMessages.appendBounded(userMsg, limit: Self.maxChatMessages) isChatThinking = true - let baseContext = """ - Mac Modeli: \(hardwareInfo.modelName) (\(hardwareInfo.chipName)), macOS Sürümü: \(hardwareInfo.osVersion) - RAM: %\(Int(memoryStats.usedPercentage * 100)) - CPU: %\(String(format: "%.1f", cpuStats.totalUsage)), Boş Disk: %\(Int(diskStats.freePercentage * 100)) - """ + let baseContext = Self.cloudSnapshotContext(hardware: hardwareInfo, memory: memoryStats, cpu: cpuStats, disk: diskStats) let context = NvidiaNIMProvider.cloudContext( baseContext, processNames: runningProcesses.prefix(4).map { "\($0.name) (PID: \($0.pid))" }, @@ -379,6 +375,17 @@ public final class AppState: ObservableObject { } } + /// System summary sent to the cloud model. Always English (model input, like the prompts); the model + /// is told separately which language to reply in. Contains no app names, file paths or secrets — + /// app names are only added by `NvidiaNIMProvider.cloudContext` when the user opted in. + nonisolated static func cloudSnapshotContext(hardware: HardwareInfo, memory: MemoryStats, cpu: CPUStats, disk: DiskStats) -> String { + """ + Mac model: \(hardware.modelName) (\(hardware.chipName)), macOS version: \(hardware.osVersion) + RAM: \(Int(memory.usedPercentage * 100))% + CPU: \(String(format: "%.1f", cpu.totalUsage))%, Free disk: \(Int(disk.freePercentage * 100))% + """ + } + public func executeAIAction(_ action: AIAction) { switch action.type { case .analyzeSystem: @@ -531,10 +538,10 @@ public final class AppState: ObservableObject { guard let self else { return } self.isScanningJunk = false if Task.isCancelled { - self.junkStatusMessage = "Tarama iptal edildi." + self.junkStatusMessage = L10n.string("Scan cancelled.") } else { self.junkGroups = groups - self.junkStatusMessage = "Tarama tamamlandı." + self.junkStatusMessage = L10n.string("Scan complete.") } } } @@ -562,7 +569,7 @@ public final class AppState: ObservableObject { Task { let confirmation = SafeOperationExecutor.confirm(plan) let result = await JunkCleanerService.shared.executeCleaningPlan(plan, confirmation: confirmation, progressHandler: self.throttledProgress { state, name, progress in - state.junkStatusMessage = "\(name) temizleniyor..." + state.junkStatusMessage = L10n.string("Cleaning %@…", name) state.junkScanProgress = progress }) @@ -572,12 +579,12 @@ public final class AppState: ObservableObject { self.scanJunk() let report = OptimizationReport( - title: "Gereksiz Dosya Temizliği (Dry-Run Onaylı)", + title: L10n.string("Junk File Cleanup (Dry-Run Approved)"), freedMemoryBytes: 0, freedDiskBytes: result.totalFreedBytes, details: [ - "\(result.cleanedItemCount) öğe başarıyla temizlendi.", - result.failedItemCount > 0 ? "\(result.failedItemCount) öğe atlandı." : "Hata oluşmadı." + L10n.string("%lld items cleaned successfully.", result.cleanedItemCount), + result.failedItemCount > 0 ? L10n.string("%lld items skipped.", result.failedItemCount) : L10n.string("No errors occurred.") ], durationSeconds: result.durationSeconds ) @@ -613,7 +620,7 @@ public final class AppState: ObservableObject { guard !isScanningApps else { return } isScanningApps = true appScanProgress = 0.0 - appStatusMessage = "Uygulamalar taranıyor..." + appStatusMessage = L10n.string("Scanning apps…") let progress = throttledProgress { state, name, progress in state.appStatusMessage = name @@ -624,10 +631,10 @@ public final class AppState: ObservableObject { guard let self else { return } self.isScanningApps = false if Task.isCancelled { - self.appStatusMessage = "Tarama iptal edildi." + self.appStatusMessage = L10n.string("Scan cancelled.") } else { self.installedApps = apps - self.appStatusMessage = "\(apps.count) uygulama bulundu." + self.appStatusMessage = L10n.string("%lld apps found.", apps.count) } } } @@ -635,7 +642,7 @@ public final class AppState: ObservableObject { public func checkAllAppUpdates() { guard !isCheckingUpdates else { return } isCheckingUpdates = true - appStatusMessage = "Güncellemeler kontrol ediliyor..." + appStatusMessage = L10n.string("Checking for updates…") Task { // Wait for an in-flight app scan so updates are checked against the complete list. @@ -649,7 +656,7 @@ public final class AppState: ObservableObject { self.installedApps = updated self.isCheckingUpdates = false let updatesFound = updated.filter { $0.updateInfo.hasUpdate }.count - self.appStatusMessage = updatesFound > 0 ? "\(updatesFound) güncelleme mevcut!" : "Tüm uygulamalar güncel." + self.appStatusMessage = updatesFound > 0 ? L10n.string("%lld updates available!", updatesFound) : L10n.string("All apps are up to date.") self.showNotification(message: self.appStatusMessage) } } @@ -682,10 +689,10 @@ public final class AppState: ObservableObject { self.refreshMetrics() let report = OptimizationReport( - title: "\(app.name) Kaldırıldı", + title: L10n.string("%@ Uninstalled", app.name), freedMemoryBytes: 0, freedDiskBytes: result.freedBytes, - details: ["\(result.deletedCount) ilişkili dosya silindi."], + details: [L10n.string("%lld associated files deleted.", result.deletedCount)], durationSeconds: 0.0 ) self.addReport(report) @@ -809,7 +816,7 @@ public final class AppState: ObservableObject { guard !isScanningDuplicates else { return } isScanningDuplicates = true duplicateScanProgress = 0.0 - duplicateStatusMessage = "Yinelenen dosyalar taranıyor..." + duplicateStatusMessage = L10n.string("Scanning for duplicate files…") let progress = throttledProgress { state, msg, progress in state.duplicateStatusMessage = msg @@ -820,12 +827,12 @@ public final class AppState: ObservableObject { guard let self else { return } self.isScanningDuplicates = false if Task.isCancelled { - self.duplicateStatusMessage = "Tarama iptal edildi." + self.duplicateStatusMessage = L10n.string("Scan cancelled.") return } self.duplicateGroups = groups self.duplicateScanProgress = 1.0 - self.duplicateStatusMessage = groups.isEmpty ? "Yinelenen dosya bulunamadı." : "\(groups.count) yinelenen dosya grubu bulundu." + self.duplicateStatusMessage = groups.isEmpty ? L10n.string("No duplicate files found.") : L10n.string("%lld duplicate file groups found.", groups.count) } } diff --git a/Sources/MacOptimizer/Resources/en.lproj/common.strings b/Sources/MacOptimizer/Resources/en.lproj/common.strings index baf491a..83ce832 100644 --- a/Sources/MacOptimizer/Resources/en.lproj/common.strings +++ b/Sources/MacOptimizer/Resources/en.lproj/common.strings @@ -38,3 +38,14 @@ "Cleaned %@ of junk files successfully." = "Cleaned %@ of junk files successfully."; "%@ and all its leftovers (%@) were removed successfully." = "%@ and all its leftovers (%@) were removed successfully."; "Moved %lld duplicate files to Trash (%@ freed)." = "Moved %lld duplicate files to Trash (%@ freed)."; +"Scan cancelled." = "Scan cancelled."; +"Scan complete." = "Scan complete."; +"Cleaning %@…" = "Cleaning %@…"; +"Junk File Cleanup (Dry-Run Approved)" = "Junk File Cleanup (Dry-Run Approved)"; +"No errors occurred." = "No errors occurred."; +"Scanning apps…" = "Scanning apps…"; +"Checking for updates…" = "Checking for updates…"; +"All apps are up to date." = "All apps are up to date."; +"%@ Uninstalled" = "%@ Uninstalled"; +"Scanning for duplicate files…" = "Scanning for duplicate files…"; +"No duplicate files found." = "No duplicate files found."; diff --git a/Sources/MacOptimizer/Resources/en.lproj/common.stringsdict b/Sources/MacOptimizer/Resources/en.lproj/common.stringsdict index 30fe7bb..25da4a9 100644 --- a/Sources/MacOptimizer/Resources/en.lproj/common.stringsdict +++ b/Sources/MacOptimizer/Resources/en.lproj/common.stringsdict @@ -10,4 +10,58 @@ otherFound and listed %lld models from NVIDIA NIM. + %lld items cleaned successfully. + NSStringLocalizedFormatKey%#@count@ + count + NSStringFormatSpecTypeKeyNSStringPluralRuleType + NSStringFormatValueTypeKeylld + oneOne item cleaned successfully. + other%lld items cleaned successfully. + + + %lld items skipped. + NSStringLocalizedFormatKey%#@count@ + count + NSStringFormatSpecTypeKeyNSStringPluralRuleType + NSStringFormatValueTypeKeylld + oneOne item skipped. + other%lld items skipped. + + + %lld apps found. + NSStringLocalizedFormatKey%#@count@ + count + NSStringFormatSpecTypeKeyNSStringPluralRuleType + NSStringFormatValueTypeKeylld + oneOne app found. + other%lld apps found. + + + %lld updates available! + NSStringLocalizedFormatKey%#@count@ + count + NSStringFormatSpecTypeKeyNSStringPluralRuleType + NSStringFormatValueTypeKeylld + oneOne update available! + other%lld updates available! + + + %lld associated files deleted. + NSStringLocalizedFormatKey%#@count@ + count + NSStringFormatSpecTypeKeyNSStringPluralRuleType + NSStringFormatValueTypeKeylld + oneOne associated file deleted. + other%lld associated files deleted. + + + %lld duplicate file groups found. + NSStringLocalizedFormatKey%#@count@ + count + NSStringFormatSpecTypeKeyNSStringPluralRuleType + NSStringFormatValueTypeKeylld + oneOne duplicate file group found. + other%lld duplicate file groups found. + + diff --git a/Sources/MacOptimizer/Resources/en.lproj/services.strings b/Sources/MacOptimizer/Resources/en.lproj/services.strings index f295f12..c1e8129 100644 --- a/Sources/MacOptimizer/Resources/en.lproj/services.strings +++ b/Sources/MacOptimizer/Resources/en.lproj/services.strings @@ -142,7 +142,7 @@ "High Memory Pressure Warning" = "High Memory Pressure Warning"; "RAM usage has reached %lld%%. Try closing the apps using the most memory to reduce memory pressure." = "RAM usage has reached %lld%%. Try closing the apps using the most memory to reduce memory pressure."; "Runaway Process: %@" = "Runaway Process: %@"; -"%@ (PID: %d) is continuously consuming %@ CPU. It may be frozen or overloaded." = "%@ (PID: %d) is continuously consuming %@ CPU. It may be frozen or overloaded."; +"%@ (PID: %d) is continuously consuming %@%% CPU. It may be frozen or overloaded." = "%@ (PID: %d) is continuously consuming %@%% CPU. It may be frozen or overloaded."; "Terminate Process" = "Terminate Process"; "Thermal Throttling / Temperature Warning" = "Thermal Throttling / Temperature Warning"; "CPU temperature has reached a critical threshold (%@). Close apps with high CPU usage to protect the hardware." = "CPU temperature has reached a critical threshold (%@). Close apps with high CPU usage to protect the hardware."; diff --git a/Sources/MacOptimizer/Resources/tr.lproj/common.strings b/Sources/MacOptimizer/Resources/tr.lproj/common.strings index 176b8b9..4392617 100644 --- a/Sources/MacOptimizer/Resources/tr.lproj/common.strings +++ b/Sources/MacOptimizer/Resources/tr.lproj/common.strings @@ -38,3 +38,14 @@ "Cleaned %@ of junk files successfully." = "%@ gereksiz dosya başarıyla temizlendi."; "%@ and all its leftovers (%@) were removed successfully." = "%@ ve tüm kalıntıları (%@) başarıyla kaldırıldı."; "Moved %lld duplicate files to Trash (%@ freed)." = "%lld yinelenen dosya Çöp Sepeti’ne taşındı (%@ alan kazanıldı)."; +"Scan cancelled." = "Tarama iptal edildi."; +"Scan complete." = "Tarama tamamlandı."; +"Cleaning %@…" = "%@ temizleniyor…"; +"Junk File Cleanup (Dry-Run Approved)" = "Gereksiz Dosya Temizliği (Dry-Run Onaylı)"; +"No errors occurred." = "Hata oluşmadı."; +"Scanning apps…" = "Uygulamalar taranıyor…"; +"Checking for updates…" = "Güncellemeler kontrol ediliyor…"; +"All apps are up to date." = "Tüm uygulamalar güncel."; +"%@ Uninstalled" = "%@ Kaldırıldı"; +"Scanning for duplicate files…" = "Yinelenen dosyalar taranıyor…"; +"No duplicate files found." = "Yinelenen dosya bulunamadı."; diff --git a/Sources/MacOptimizer/Resources/tr.lproj/common.stringsdict b/Sources/MacOptimizer/Resources/tr.lproj/common.stringsdict index f178644..dcd6285 100644 --- a/Sources/MacOptimizer/Resources/tr.lproj/common.stringsdict +++ b/Sources/MacOptimizer/Resources/tr.lproj/common.stringsdict @@ -10,4 +10,58 @@ otherNVIDIA NIM üzerinden %lld model bulundu ve listelendi. + %lld items cleaned successfully. + NSStringLocalizedFormatKey%#@count@ + count + NSStringFormatSpecTypeKeyNSStringPluralRuleType + NSStringFormatValueTypeKeylld + oneBir öğe başarıyla temizlendi. + other%lld öğe başarıyla temizlendi. + + + %lld items skipped. + NSStringLocalizedFormatKey%#@count@ + count + NSStringFormatSpecTypeKeyNSStringPluralRuleType + NSStringFormatValueTypeKeylld + oneBir öğe atlandı. + other%lld öğe atlandı. + + + %lld apps found. + NSStringLocalizedFormatKey%#@count@ + count + NSStringFormatSpecTypeKeyNSStringPluralRuleType + NSStringFormatValueTypeKeylld + oneBir uygulama bulundu. + other%lld uygulama bulundu. + + + %lld updates available! + NSStringLocalizedFormatKey%#@count@ + count + NSStringFormatSpecTypeKeyNSStringPluralRuleType + NSStringFormatValueTypeKeylld + oneBir güncelleme mevcut! + other%lld güncelleme mevcut! + + + %lld associated files deleted. + NSStringLocalizedFormatKey%#@count@ + count + NSStringFormatSpecTypeKeyNSStringPluralRuleType + NSStringFormatValueTypeKeylld + oneBir ilişkili dosya silindi. + other%lld ilişkili dosya silindi. + + + %lld duplicate file groups found. + NSStringLocalizedFormatKey%#@count@ + count + NSStringFormatSpecTypeKeyNSStringPluralRuleType + NSStringFormatValueTypeKeylld + oneBir yinelenen dosya grubu bulundu. + other%lld yinelenen dosya grubu bulundu. + + diff --git a/Sources/MacOptimizer/Resources/tr.lproj/services.strings b/Sources/MacOptimizer/Resources/tr.lproj/services.strings index 32e6736..2ae7ee7 100644 --- a/Sources/MacOptimizer/Resources/tr.lproj/services.strings +++ b/Sources/MacOptimizer/Resources/tr.lproj/services.strings @@ -142,7 +142,7 @@ "High Memory Pressure Warning" = "Yüksek Bellek Baskısı Uyarısı"; "RAM usage has reached %lld%%. Try closing the apps using the most memory to reduce memory pressure." = "RAM kullanımı %%%lld seviyesine ulaştı. Bellek baskısını azaltmak için en çok bellek kullanan uygulamaları kapatmayı deneyin."; "Runaway Process: %@" = "Kaçak Süreç: %@"; -"%@ (PID: %d) is continuously consuming %@ CPU. It may be frozen or overloaded." = "%@ (PID: %d) sürekli olarak %%%@ işlemci tüketiyor. Donmuş veya aşırı yüklenmiş olabilir."; +"%@ (PID: %d) is continuously consuming %@%% CPU. It may be frozen or overloaded." = "%@ (PID: %d) sürekli olarak %%%@ işlemci tüketiyor. Donmuş veya aşırı yüklenmiş olabilir."; "Terminate Process" = "İşlemi Sonlandır"; "Thermal Throttling / Temperature Warning" = "Termal Kısılma / Sıcaklık Uyarısı"; "CPU temperature has reached a critical threshold (%@). Close apps with high CPU usage to protect the hardware." = "İşlemci sıcaklığı kritik eşiğe ulaştı (%@). Donanımı korumak için yüksek işlemci kullanan uygulamaları kapatın."; diff --git a/Sources/MacOptimizer/Services/AutonomousGuardService.swift b/Sources/MacOptimizer/Services/AutonomousGuardService.swift index a047c2d..70a9fe0 100644 --- a/Sources/MacOptimizer/Services/AutonomousGuardService.swift +++ b/Sources/MacOptimizer/Services/AutonomousGuardService.swift @@ -85,7 +85,7 @@ public actor AutonomousGuardService { if count >= 3 { // Detected high CPU for 3 consecutive samples let alert = AutonomousAlert( title: L10n.string("Runaway Process: %@", table: .services, proc.name), - message: L10n.string("%@ (PID: %d) is continuously consuming %@ CPU. It may be frozen or overloaded.", table: .services, proc.name, proc.pid, proc.cpuFormatted), + message: L10n.string("%@ (PID: %d) is continuously consuming %@%% CPU. It may be frozen or overloaded.", table: .services, proc.name, proc.pid, String(format: "%.1f", proc.cpuPercentage)), type: .runawayProcess, timestamp: now, isResolved: false, diff --git a/Tests/MacOptimizerTests/LocalizationTests.swift b/Tests/MacOptimizerTests/LocalizationTests.swift index 7ce10dd..ee6921b 100644 --- a/Tests/MacOptimizerTests/LocalizationTests.swift +++ b/Tests/MacOptimizerTests/LocalizationTests.swift @@ -85,9 +85,10 @@ final class LocalizationTests: XCTestCase { } private func formatSpecifiers(in value: String) -> [String] { - let pattern = #"%(?:[0-9]+\$)?[-+#0 ]*(?:[0-9]+|\*)?(?:\.(?:[0-9]+|\*))?(?:hh|h|ll|l|L|z|t|j)?[@diuoxXfFeEgGaAcCsSp]"# + // "%%" is a literal percent sign, not a specifier; match it first so "%@%% CPU" is not read as "% C". + let pattern = #"%%|%(?:[0-9]+\$)?[-+#0 ]*(?:[0-9]+|\*)?(?:\.(?:[0-9]+|\*))?(?:hh|h|ll|l|L|z|t|j)?[@diuoxXfFeEgGaAcCsSp]"# guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] } let range = NSRange(value.startIndex..., in: value) - return regex.matches(in: value, range: range).compactMap { Range($0.range, in: value).map { String(value[$0]) } }.sorted() + return regex.matches(in: value, range: range).compactMap { Range($0.range, in: value).map { String(value[$0]) } }.filter { $0 != "%%" }.sorted() } } diff --git a/Tests/MacOptimizerTests/MacOptimizerTests.swift b/Tests/MacOptimizerTests/MacOptimizerTests.swift index 6525b92..5d70445 100644 --- a/Tests/MacOptimizerTests/MacOptimizerTests.swift +++ b/Tests/MacOptimizerTests/MacOptimizerTests.swift @@ -509,6 +509,52 @@ final class MacOptimizerTests: XCTestCase { XCTAssertEqual((decoded?["messages"] as? [[String: String]])?.count, 7) } + func testCloudSnapshotContextIsEnglishAndPrivate() { + var memory = MemoryStats() + memory.totalBytes = 16_000_000_000 + memory.activeBytes = 8_000_000_000 + var cpu = CPUStats() + cpu.totalUsage = 12.34 + var disk = DiskStats() + disk.totalBytes = 500_000_000_000 + disk.freeBytes = 250_000_000_000 + let context = AppState.cloudSnapshotContext(hardware: HardwareInfo(), memory: memory, cpu: cpu, disk: disk) + XCTAssertTrue(context.hasPrefix("Mac model: ")) + XCTAssertTrue(context.contains("macOS version: ")) + XCTAssertTrue(context.contains("CPU: 12.3%")) + XCTAssertTrue(context.contains("Free disk: 50%")) + for turkish in ["Mac Modeli", "Sürümü", "Boş Disk"] { XCTAssertFalse(context.contains(turkish)) } + XCTAssertFalse(context.contains("/Users/")) + XCTAssertFalse(context.contains("Running apps")) + XCTAssertFalse(context.contains("PID")) + } + + func testRunawayMessageShowsPercentOnceInBothLanguages() async { + let prior = UserDefaults.standard.object(forKey: "AppleLanguages") + defer { + if let prior { UserDefaults.standard.set(prior, forKey: "AppleLanguages") } + else { UserDefaults.standard.removeObject(forKey: "AppleLanguages") } + } + var config = AutonomousConfig() + config.isWatchdogActive = true + config.cpuRunawayThresholdPercent = 80 + let hot = ProcessInfoModel(pid: 4244, name: "HotApp", path: "/Applications/HotApp.app/Contents/MacOS/HotApp", cpuPercentage: 97.34) + var mem = MemoryStats() + mem.totalBytes = 16_000_000_000 + + for (language, expected) in [("en", "97.3% CPU"), ("tr", "%97.3 işlemci")] { + UserDefaults.standard.set([language], forKey: "AppleLanguages") + let guardService = AutonomousGuardService() + var alerts: [AutonomousAlert] = [] + for _ in 0..<3 { + alerts += await guardService.evaluateCycle(memory: mem, cpu: CPUStats(), disk: DiskStats(), processes: [hot], config: config) + } + let message = alerts.first { $0.type == .runawayProcess }?.message ?? "" + XCTAssertEqual(message.filter { $0 == "%" }.count, 1, "\(language): \(message)") + XCTAssertTrue(message.contains(expected), "\(language): \(message)") + } + } + func testNIMPromptsAreEnglishAndNameReplyLanguage() { let system = NvidiaNIMProvider.copilotSystemPrompt(snapshotContext: "RAM 50%", replyLanguage: "Turkish") XCTAssertTrue(system.hasPrefix("You are the MacOptimizer Pro macOS assistant.")) @@ -665,11 +711,13 @@ final class MacOptimizerTests: XCTestCase { ) XCTAssertFalse(alerts.isEmpty, "Watchdog should generate alerts for critical metrics") - let titles = alerts.map { $0.title } - XCTAssertTrue(titles.contains(where: { $0.contains("Bellek") || $0.contains("RAM") || $0.contains("Memory") })) - XCTAssertTrue(titles.contains(where: { $0.contains("Termal") || $0.contains("Sıcaklık") || $0.contains("Thermal") })) - XCTAssertTrue(titles.contains(where: { $0.contains("Swap") })) - XCTAssertTrue(titles.contains(where: { $0.contains("Disk") })) + // Titles follow the UI language, so compare against the localized value rather than literal text. + let titles = Set(alerts.map { $0.title }) + XCTAssertTrue(titles.contains(L10n.string("High Memory Pressure Warning", table: .services))) + XCTAssertTrue(titles.contains(L10n.string("Thermal Throttling / Temperature Warning", table: .services))) + XCTAssertTrue(titles.contains(L10n.string("High Swap Usage", table: .services))) + XCTAssertTrue(titles.contains(L10n.string("Low Disk Space Warning", table: .services))) + XCTAssertTrue(alerts.contains { $0.type == .lowDisk }) } func testAutonomousGuardSkipsProtectedSystemProcesses() async {