From 0b1d5398a4a06cffb214e3782091a28cd9a40041 Mon Sep 17 00:00:00 2001 From: SmokeStudios <290871035+OGSmokeStudios@users.noreply.github.com> Date: Sat, 19 Sep 2026 11:57:32 +0200 Subject: [PATCH 1/4] Serialize launch ownership and fix privacy, process identity, and weather lookups --- Core-Monitor.xcodeproj/project.pbxproj | 12 ++ Core-Monitor/AlertManager.swift | 1 + Core-Monitor/Core_MonitorApp.swift | 175 +++++++++++++----- Core-Monitor/DiskProcessSampler.swift | 19 +- Core-Monitor/PrivacySettings.swift | 2 +- Core-Monitor/SettingsWindow.swift | 2 +- Core-Monitor/SingleInstanceLock.swift | 61 ++++++ Core-Monitor/WeatherLocationNameCache.swift | 59 ++++++ Core-Monitor/WeatherService.swift | 23 +-- ...CoreMonitorSingleInstancePolicyTests.swift | 92 ++------- .../DiskProcessSamplerTests.swift | 1 + .../FollowupRegressionTests.swift | 133 +++++++++++++ 12 files changed, 437 insertions(+), 143 deletions(-) create mode 100644 Core-Monitor/SingleInstanceLock.swift create mode 100644 Core-Monitor/WeatherLocationNameCache.swift create mode 100644 Core-MonitorTests/FollowupRegressionTests.swift diff --git a/Core-Monitor.xcodeproj/project.pbxproj b/Core-Monitor.xcodeproj/project.pbxproj index c77cae8d..61819a2f 100644 --- a/Core-Monitor.xcodeproj/project.pbxproj +++ b/Core-Monitor.xcodeproj/project.pbxproj @@ -7,6 +7,9 @@ objects = { /* Begin PBXBuildFile section */ + A19700032026091900000002 /* DiskProcessSamplerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A19700032026091900000001 /* DiskProcessSamplerTests.swift */; }; + A19700022026091900000002 /* CoreMonitorSingleInstancePolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A19700022026091900000001 /* CoreMonitorSingleInstancePolicyTests.swift */; }; + A19700012026091900000002 /* FollowupRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A19700012026091900000001 /* FollowupRegressionTests.swift */; }; A19600032026091900000002 /* BatteryDetailFormatterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A19600032026091900000001 /* BatteryDetailFormatterTests.swift */; }; A19600022026091900000002 /* HelperXPCClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A19600022026091900000001 /* HelperXPCClientTests.swift */; }; A19600012026091900000002 /* RuntimeSafetyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A19600012026091900000001 /* RuntimeSafetyTests.swift */; }; @@ -54,6 +57,9 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + A19700032026091900000001 /* DiskProcessSamplerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiskProcessSamplerTests.swift; sourceTree = ""; }; + A19700022026091900000001 /* CoreMonitorSingleInstancePolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreMonitorSingleInstancePolicyTests.swift; sourceTree = ""; }; + A19700012026091900000001 /* FollowupRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowupRegressionTests.swift; sourceTree = ""; }; A19600032026091900000001 /* BatteryDetailFormatterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryDetailFormatterTests.swift; sourceTree = ""; }; A19600022026091900000001 /* HelperXPCClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HelperXPCClientTests.swift; sourceTree = ""; }; A19600012026091900000001 /* RuntimeSafetyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuntimeSafetyTests.swift; sourceTree = ""; }; @@ -121,6 +127,9 @@ 1A2F97F19E4BA4C0B1070411 /* Core-MonitorTests */ = { isa = PBXGroup; children = ( + A19700032026091900000001 /* DiskProcessSamplerTests.swift */, + A19700022026091900000001 /* CoreMonitorSingleInstancePolicyTests.swift */, + A19700012026091900000001 /* FollowupRegressionTests.swift */, A19600032026091900000001 /* BatteryDetailFormatterTests.swift */, A19600022026091900000001 /* HelperXPCClientTests.swift */, A19600012026091900000001 /* RuntimeSafetyTests.swift */, @@ -340,6 +349,9 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + A19700032026091900000002 /* DiskProcessSamplerTests.swift in Sources */, + A19700022026091900000002 /* CoreMonitorSingleInstancePolicyTests.swift in Sources */, + A19700012026091900000002 /* FollowupRegressionTests.swift in Sources */, A19600032026091900000002 /* BatteryDetailFormatterTests.swift in Sources */, A19600022026091900000002 /* HelperXPCClientTests.swift in Sources */, A19600012026091900000002 /* RuntimeSafetyTests.swift in Sources */, diff --git a/Core-Monitor/AlertManager.swift b/Core-Monitor/AlertManager.swift index 811461c3..9643bb16 100644 --- a/Core-Monitor/AlertManager.swift +++ b/Core-Monitor/AlertManager.swift @@ -274,6 +274,7 @@ final class AlertManager: NSObject, ObservableObject { privacySettings.$processInsightsEnabled .removeDuplicates() + .receive(on: RunLoop.main) .sink { [weak self] enabled in guard let self else { return } self.processInsightsEnabled = enabled diff --git a/Core-Monitor/Core_MonitorApp.swift b/Core-Monitor/Core_MonitorApp.swift index a902d997..1140d2e1 100644 --- a/Core-Monitor/Core_MonitorApp.swift +++ b/Core-Monitor/Core_MonitorApp.swift @@ -13,23 +13,13 @@ struct CoreMonitorRunningInstance: Equatable { enum CoreMonitorSingleInstancePolicy { static func handoffTarget( from runningInstances: [CoreMonitorRunningInstance], - currentPID: pid_t + currentPID: pid_t, + ownerPID: pid_t? ) -> CoreMonitorRunningInstance? { - runningInstances - .filter { instance in - instance.processIdentifier != currentPID && - instance.isFinishedLaunching && - instance.isTerminated == false - } - .sorted { lhs, rhs in - let lhsLaunchDate = lhs.launchDate ?? .distantPast - let rhsLaunchDate = rhs.launchDate ?? .distantPast - if lhsLaunchDate != rhsLaunchDate { - return lhsLaunchDate < rhsLaunchDate - } - return lhs.processIdentifier < rhs.processIdentifier - } - .first + guard let ownerPID, ownerPID != currentPID else { return nil } + // The kernel lock elects the owner. A launching owner is valid; the + // sender retries until its dashboard observer can acknowledge delivery. + return runningInstances.first { $0.processIdentifier == ownerPID && !$0.isTerminated } } } @@ -39,14 +29,35 @@ struct CoreMonitorDashboardHandoffRequest: Equatable { let bundleIdentifier: String let targetProcessIdentifier: pid_t + let requestIdentifier: UUID + let requesterProcessIdentifier: pid_t + + init(bundleIdentifier: String, targetProcessIdentifier: pid_t, + requestIdentifier: UUID = UUID(), requesterProcessIdentifier: pid_t = ProcessInfo.processInfo.processIdentifier) { + self.bundleIdentifier = bundleIdentifier + self.targetProcessIdentifier = targetProcessIdentifier + self.requestIdentifier = requestIdentifier + self.requesterProcessIdentifier = requesterProcessIdentifier + } var userInfo: [AnyHashable: Any] { [ Self.bundleIdentifierKey: bundleIdentifier, - Self.targetProcessIdentifierKey: NSNumber(value: targetProcessIdentifier) + Self.targetProcessIdentifierKey: NSNumber(value: targetProcessIdentifier), + "requestIdentifier": requestIdentifier.uuidString, + "requesterProcessIdentifier": NSNumber(value: requesterProcessIdentifier) ] } + static func acceptsAcknowledgement( + userInfo: [AnyHashable: Any]?, bundleIdentifier: String, + requestIdentifier: UUID, requesterPID: pid_t + ) -> Bool { + userInfo?[bundleIdentifierKey] as? String == bundleIdentifier && + userInfo?["requestIdentifier"] as? String == requestIdentifier.uuidString && + (userInfo?["requesterProcessIdentifier"] as? NSNumber)?.int32Value == requesterPID + } + static func accepts( userInfo: [AnyHashable: Any]?, expectedBundleIdentifier: String, @@ -189,6 +200,7 @@ private final class DashboardWindowController: NSWindowController, NSWindowDeleg @MainActor final class CoreMonitorApplicationDelegate: NSObject, NSApplicationDelegate { private static let openDashboardRequestNotification = Notification.Name("CoreMonitorOpenDashboardRequest") + private static let dashboardAcknowledgementNotification = Notification.Name("CoreMonitorDashboardAcknowledgement") private static let automaticTerminationReason = "Core Monitor keeps menu bar monitoring active." private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "CoreTools.Core-Monitor", category: "Startup") @@ -199,6 +211,11 @@ final class CoreMonitorApplicationDelegate: NSObject, NSApplicationDelegate { private var menuBarController: MenuBarController? private var dashboardController: DashboardWindowController? private var hasPresentedInitialDashboard = false + private var didBootstrapPrimaryInstance = false + private var instanceLock: SingleInstanceLock? + private var handoffTask: Task? + private var acknowledgedHandoff: UUID? + private var lastDashboardRequestIdentifier: String? private var pendingInitialDashboardAttempts: [DispatchWorkItem] = [] private var quitShortcutMonitor: Any? private var touchBarShortcutMonitor: Any? @@ -215,6 +232,12 @@ final class CoreMonitorApplicationDelegate: NSObject, NSApplicationDelegate { NSWindow.allowsAutomaticWindowTabbing = false ProcessInfo.processInfo.disableAutomaticTermination(Self.automaticTerminationReason) guard handOffToRunningInstanceIfNeeded() == false else { return } + bootstrapPrimaryInstance() + } + + private func bootstrapPrimaryInstance() { + guard !didBootstrapPrimaryInstance else { return } + didBootstrapPrimaryInstance = true CoreMonitorDefaultsMaintenance.purgeDeprecatedState() SettingsWindowManager.shared.configure( systemMonitor: coordinator.systemMonitor, @@ -258,8 +281,9 @@ final class CoreMonitorApplicationDelegate: NSObject, NSApplicationDelegate { self.dashboardShortcutObserver = nil } guard shouldBootstrapInteractiveApp else { return } + handoffTask?.cancel() cancelInitialDashboardAttempts() - coordinator.stop() + if didBootstrapPrimaryInstance { coordinator.stop() } } func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { @@ -425,8 +449,20 @@ final class CoreMonitorApplicationDelegate: NSObject, NSApplicationDelegate { ) else { return } + let info = notification.userInfo Task { @MainActor [weak self] in - self?.openDashboard() + guard let self else { return } + let requestID = info?["requestIdentifier"] as? String + if requestID == nil || requestID != lastDashboardRequestIdentifier { + openDashboard() + lastDashboardRequestIdentifier = requestID + } + if requestID != nil, dashboardController?.isDashboardVisible == true { + DistributedNotificationCenter.default().postNotificationName( + Self.dashboardAcknowledgementNotification, object: bundleIdentifier, + userInfo: info, deliverImmediately: true + ) + } } } } @@ -509,41 +545,78 @@ final class CoreMonitorApplicationDelegate: NSObject, NSApplicationDelegate { } private func handOffToRunningInstanceIfNeeded() -> Bool { - guard CoreMonitorLaunchEnvironment.shouldHandleDuplicateLaunch() else { return false } - guard let bundleIdentifier = Bundle.main.bundleIdentifier else { return false } - - let currentPID = ProcessInfo.processInfo.processIdentifier - let runningApplications = NSRunningApplication.runningApplications(withBundleIdentifier: bundleIdentifier) - let runningInstances = runningApplications.map { - CoreMonitorRunningInstance( - processIdentifier: $0.processIdentifier, - launchDate: $0.launchDate, - isFinishedLaunching: $0.isFinishedLaunching, - isTerminated: $0.isTerminated - ) - } - - guard let target = CoreMonitorSingleInstancePolicy.handoffTarget( - from: runningInstances, - currentPID: currentPID - ), let targetApplication = runningApplications.first(where: { $0.processIdentifier == target.processIdentifier }) else { - return false + guard CoreMonitorLaunchEnvironment.shouldHandleDuplicateLaunch(), + let bundleIdentifier = Bundle.main.bundleIdentifier else { return false } + let lock: SingleInstanceLock + do { + lock = SingleInstanceLock(fileURL: try SingleInstanceLock.fileURL(bundleIdentifier: bundleIdentifier)) + instanceLock = lock + if try lock.acquire() { return false } + } catch { + NSApp.presentError(error) + DispatchQueue.main.async { NSApp.terminate(nil) } + return true } - let request = CoreMonitorDashboardHandoffRequest( - bundleIdentifier: bundleIdentifier, - targetProcessIdentifier: target.processIdentifier - ) - - DistributedNotificationCenter.default().postNotificationName( - Self.openDashboardRequestNotification, - object: bundleIdentifier, - userInfo: request.userInfo, - deliverImmediately: true - ) - _ = targetApplication.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) NSApp.setActivationPolicy(.accessory) - DispatchQueue.main.async { + let currentPID = ProcessInfo.processInfo.processIdentifier + let requestID = UUID() + handoffTask = Task { @MainActor [weak self] in + guard let self else { return } + let observer = DistributedNotificationCenter.default().addObserver( + forName: Self.dashboardAcknowledgementNotification, object: bundleIdentifier, queue: .main + ) { [weak self] notification in + guard CoreMonitorDashboardHandoffRequest.acceptsAcknowledgement( + userInfo: notification.userInfo, bundleIdentifier: bundleIdentifier, + requestIdentifier: requestID, requesterPID: currentPID + ) else { return } + Task { @MainActor [weak self] in self?.acknowledgedHandoff = requestID } + } + defer { DistributedNotificationCenter.default().removeObserver(observer) } + + for _ in 0..<50 { + guard !Task.isCancelled else { return } + do { + // If the owner exits before acknowledging, take over its + // released kernel lock instead of leaving no running app. + if try lock.acquire() { + bootstrapPrimaryInstance() + return + } + } catch { + NSApp.presentError(error) + NSApp.terminate(nil) + return + } + let applications = NSRunningApplication.runningApplications(withBundleIdentifier: bundleIdentifier) + let instances = applications.map { + CoreMonitorRunningInstance(processIdentifier: $0.processIdentifier, launchDate: $0.launchDate, + isFinishedLaunching: $0.isFinishedLaunching, isTerminated: $0.isTerminated) + } + if let target = CoreMonitorSingleInstancePolicy.handoffTarget( + from: instances, currentPID: currentPID, ownerPID: lock.ownerPID + ), let application = applications.first(where: { $0.processIdentifier == target.processIdentifier }) { + if acknowledgedHandoff == requestID { + NSApp.terminate(nil) + return + } + let request = CoreMonitorDashboardHandoffRequest(bundleIdentifier: bundleIdentifier, + targetProcessIdentifier: target.processIdentifier, requestIdentifier: requestID, + requesterProcessIdentifier: currentPID) + DistributedNotificationCenter.default().postNotificationName( + Self.openDashboardRequestNotification, object: bundleIdentifier, + userInfo: request.userInfo, deliverImmediately: true + ) + if target.isFinishedLaunching { + _ = application.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) + } + } + try? await Task.sleep(nanoseconds: 200_000_000) + } + let alert = NSAlert() + alert.messageText = "Core Monitor is already running" + alert.informativeText = "The running app has not responded. Switch to it, or quit it before opening Core Monitor again." + alert.runModal() NSApp.terminate(nil) } return true diff --git a/Core-Monitor/DiskProcessSampler.swift b/Core-Monitor/DiskProcessSampler.swift index 9a9cac65..cf326219 100644 --- a/Core-Monitor/DiskProcessSampler.swift +++ b/Core-Monitor/DiskProcessSampler.swift @@ -32,6 +32,15 @@ struct DiskProcessCounter: Equatable { let name: String let readBytes: UInt64 let writtenBytes: UInt64 + let startTime: UInt64 + + init(pid: pid_t, name: String, readBytes: UInt64, writtenBytes: UInt64, startTime: UInt64 = 0) { + self.pid = pid + self.name = name + self.readBytes = readBytes + self.writtenBytes = writtenBytes + self.startTime = startTime + } } enum DiskProcessSampling { @@ -43,8 +52,11 @@ enum DiskProcessSampling { var aggregatedByName: [String: DiskProcessActivity] = [:] for counter in counters { - let readDelta = deltaBytes(current: counter.readBytes, previous: previousCounters[counter.pid]?.readBytes) - let writeDelta = deltaBytes(current: counter.writtenBytes, previous: previousCounters[counter.pid]?.writtenBytes) + let previous = previousCounters[counter.pid].flatMap { + $0.startTime == counter.startTime ? $0 : nil + } + let readDelta = deltaBytes(current: counter.readBytes, previous: previous?.readBytes) + let writeDelta = deltaBytes(current: counter.writtenBytes, previous: previous?.writtenBytes) guard readDelta > 0 || writeDelta > 0 else { continue } let current = aggregatedByName[counter.name] ?? DiskProcessActivity( @@ -101,7 +113,8 @@ enum DiskProcessSampling { pid: pid, name: displayName(for: pid), readBytes: usage.ri_diskio_bytesread, - writtenBytes: usage.ri_diskio_byteswritten + writtenBytes: usage.ri_diskio_byteswritten, + startTime: usage.ri_proc_start_abstime ) } } diff --git a/Core-Monitor/PrivacySettings.swift b/Core-Monitor/PrivacySettings.swift index acc8f266..74eb49e6 100644 --- a/Core-Monitor/PrivacySettings.swift +++ b/Core-Monitor/PrivacySettings.swift @@ -16,7 +16,7 @@ final class PrivacySettings: ObservableObject { private let defaults: UserDefaults - private init(defaults: UserDefaults = .standard) { + init(defaults: UserDefaults = .standard) { self.defaults = defaults if defaults.object(forKey: Key.processInsightsEnabled) == nil { diff --git a/Core-Monitor/SettingsWindow.swift b/Core-Monitor/SettingsWindow.swift index 9b101df8..67a28235 100644 --- a/Core-Monitor/SettingsWindow.swift +++ b/Core-Monitor/SettingsWindow.swift @@ -278,7 +278,7 @@ private struct TouchBarSettingsTab: View { } header: { Text("Live Weather") } footer: { - Text("Off by default. Enabling this adds the Weather widget, requests location access, and contacts Apple WeatherKit and CoreLocation. Core-Monitor does not receive this data.") + Text("Off by default. Enabling weather requests location access. The app uses your location on this Mac and sends it to Apple WeatherKit and CoreLocation to retrieve weather and place names. Core-Monitor's developer does not receive your location or weather data.") } Section("Presets") { diff --git a/Core-Monitor/SingleInstanceLock.swift b/Core-Monitor/SingleInstanceLock.swift new file mode 100644 index 00000000..83d535cf --- /dev/null +++ b/Core-Monitor/SingleInstanceLock.swift @@ -0,0 +1,61 @@ +import Darwin +import Foundation + +/// The open descriptor owns the lock. Never unlink the file: replacing its +/// inode would let two processes acquire different locks for the same app. +@MainActor +final class SingleInstanceLock { + private let fileURL: URL + private var descriptor: Int32? + + init(fileURL: URL) { self.fileURL = fileURL } + + deinit { if let descriptor { Darwin.close(descriptor) } } + + static func fileURL(bundleIdentifier: String) throws -> URL { + let caches = try FileManager.default.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + let name = bundleIdentifier.replacingOccurrences(of: "/", with: "_") + return caches.appendingPathComponent(name, isDirectory: true).appendingPathComponent("single-instance.lock") + } + + func acquire() throws -> Bool { + if descriptor != nil { return true } + try FileManager.default.createDirectory(at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700]) + let fd = Darwin.open(fileURL.path, O_RDWR | O_CREAT | O_CLOEXEC | O_NOFOLLOW, mode_t(0o600)) + guard fd >= 0 else { throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) } + var keepDescriptor = false + defer { if !keepDescriptor { Darwin.close(fd) } } + var metadata = stat() + guard fstat(fd, &metadata) == 0, + metadata.st_mode & mode_t(S_IFMT) == mode_t(S_IFREG), + metadata.st_uid == geteuid(), metadata.st_nlink == 1 else { + throw POSIXError(.EINVAL) + } + guard flock(fd, LOCK_EX | LOCK_NB) == 0 else { + let code = errno + if code == EWOULDBLOCK || code == EAGAIN { return false } + throw POSIXError(POSIXErrorCode(rawValue: code) ?? .EIO) + } + let pid = Data("\(getpid())\n".utf8) + guard ftruncate(fd, 0) == 0 else { throw POSIXError(.EIO) } + let written = pid.withUnsafeBytes { Darwin.write(fd, $0.baseAddress, $0.count) } + guard written == pid.count else { throw POSIXError(.EIO) } + descriptor = fd + keepDescriptor = true + return true + } + + var ownerPID: pid_t? { + guard let file = try? FileHandle(forReadingFrom: fileURL) else { return nil } + defer { try? file.close() } + guard let data = try? file.read(upToCount: 32), + let text = String(data: data, encoding: .utf8), + let pid = pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines)), pid > 0 else { return nil } + return pid + } + + func release() { + if let descriptor { Darwin.close(descriptor) } + descriptor = nil + } +} diff --git a/Core-Monitor/WeatherLocationNameCache.swift b/Core-Monitor/WeatherLocationNameCache.swift new file mode 100644 index 00000000..b73e2936 --- /dev/null +++ b/Core-Monitor/WeatherLocationNameCache.swift @@ -0,0 +1,59 @@ +import CoreLocation +import Foundation + +/// An in-memory cache of the most recent place name. Location changes, language +/// changes, and expiry invalidate it; failed lookups are never cached. +actor WeatherLocationNameCache { + private struct Entry { + let location: CLLocation + let localeIdentifier: String + let name: String + let fetchedAt: Date + } + private struct Pending { + let id: UUID + let location: CLLocation + let localeIdentifier: String + let task: Task + } + + private let lifetime: TimeInterval + private let distanceThreshold: CLLocationDistance + private var cached: Entry? + private var pending: Pending? + + init(lifetime: TimeInterval = 3_600, distanceThreshold: CLLocationDistance = 500) { + self.lifetime = lifetime + self.distanceThreshold = distanceThreshold + } + + func name( + for location: CLLocation, + locale: Locale, + now: Date = Date(), + resolve: @escaping @Sendable (CLLocation, Locale) async throws -> String? + ) async -> String { + if let cached, cached.localeIdentifier == locale.identifier, + now.timeIntervalSince(cached.fetchedAt) >= 0, + now.timeIntervalSince(cached.fetchedAt) < lifetime, + location.distance(from: cached.location) <= distanceThreshold { + return cached.name + } + if let pending, pending.localeIdentifier == locale.identifier, + location.distance(from: pending.location) <= distanceThreshold { + return (try? await pending.task.value) ?? "Weather" + } + + let id = UUID() + let task = Task { try await resolve(location, locale) } + pending = Pending(id: id, location: location, localeIdentifier: locale.identifier, task: task) + let resolved = try? await task.value + if pending?.id == id { + pending = nil + if let resolved, !resolved.isEmpty { + cached = Entry(location: location, localeIdentifier: locale.identifier, name: resolved, fetchedAt: now) + } + } + return resolved.flatMap { $0.isEmpty ? nil : $0 } ?? "Weather" + } +} diff --git a/Core-Monitor/WeatherService.swift b/Core-Monitor/WeatherService.swift index 3b4bb691..59945dce 100644 --- a/Core-Monitor/WeatherService.swift +++ b/Core-Monitor/WeatherService.swift @@ -236,10 +236,14 @@ final class WeatherLocationAccessController: NSObject, ObservableObject, CLLocat @available(macOS 13.0, *) @MainActor final class LiveWeatherService: WeatherProviding { + private nonisolated let locationNames = WeatherLocationNameCache() + nonisolated func currentWeather(for location: CLLocation) async throws -> WeatherSnapshot { let weather = try await WeatherService.shared.weather(for: location) let current = weather.currentWeather - let locationName = await Self.locationName(for: location) + let locationName = await locationNames.name(for: location, locale: AppLocaleStore.currentLocale) { location, locale in + try await Self.locationName(for: location, locale: locale) + } let nextRainSummary = Self.nextRainSummary(from: weather) return WeatherSnapshot( @@ -295,19 +299,10 @@ final class LiveWeatherService: WeatherProviding { return raw.contains("rain") || raw.contains("drizzle") || raw.contains("thunder") } - private nonisolated static func locationName(for location: CLLocation) async -> String { - do { - let placemarks = try await CLGeocoder().reverseGeocodeLocation(location) - if let place = placemarks.first { - return place.locality - ?? place.subAdministrativeArea - ?? place.name - ?? "Weather" - } - } catch { - // Keep the WeatherKit fetch path intact and fall back to a generic title. - } - return "Weather" + private nonisolated static func locationName(for location: CLLocation, locale: Locale) async throws -> String? { + let placemarks = try await CLGeocoder().reverseGeocodeLocation(location, preferredLocale: locale) + guard let place = placemarks.first else { return nil } + return place.locality ?? place.subAdministrativeArea ?? place.name } } diff --git a/Core-MonitorTests/CoreMonitorSingleInstancePolicyTests.swift b/Core-MonitorTests/CoreMonitorSingleInstancePolicyTests.swift index 79288424..d45e3c7e 100644 --- a/Core-MonitorTests/CoreMonitorSingleInstancePolicyTests.swift +++ b/Core-MonitorTests/CoreMonitorSingleInstancePolicyTests.swift @@ -1,84 +1,30 @@ import XCTest @testable import Core_Monitor +@MainActor final class CoreMonitorSingleInstancePolicyTests: XCTestCase { - func testHandoffTargetIgnoresCurrentProcessAndUnreadyPeers() { - let currentPID = pid_t(900) - let launchDate = Date(timeIntervalSince1970: 100) - - let candidates = [ - CoreMonitorRunningInstance( - processIdentifier: currentPID, - launchDate: launchDate, - isFinishedLaunching: true, - isTerminated: false - ), - CoreMonitorRunningInstance( - processIdentifier: 901, - launchDate: Date(timeIntervalSince1970: 90), - isFinishedLaunching: false, - isTerminated: false - ), - CoreMonitorRunningInstance( - processIdentifier: 902, - launchDate: Date(timeIntervalSince1970: 80), - isFinishedLaunching: true, - isTerminated: true - ) - ] - - XCTAssertNil( - CoreMonitorSingleInstancePolicy.handoffTarget( - from: candidates, - currentPID: currentPID - ) - ) + func testOnlyLockOwnerIsSelectedEvenIfItIsStillLaunching() { + let owner = CoreMonitorRunningInstance(processIdentifier: 901, launchDate: Date(), isFinishedLaunching: false, isTerminated: false) + let older = CoreMonitorRunningInstance(processIdentifier: 800, launchDate: .distantPast, isFinishedLaunching: true, isTerminated: false) + XCTAssertEqual(CoreMonitorSingleInstancePolicy.handoffTarget(from: [older, owner], currentPID: 900, ownerPID: 901), owner) + XCTAssertNil(CoreMonitorSingleInstancePolicy.handoffTarget(from: [older, owner], currentPID: 900, ownerPID: nil)) + XCTAssertNil(CoreMonitorSingleInstancePolicy.handoffTarget(from: [owner], currentPID: 901, ownerPID: 901)) } - func testHandoffTargetPrefersOldestFinishedRunningInstance() { - let currentPID = pid_t(900) - let oldest = CoreMonitorRunningInstance( - processIdentifier: 800, - launchDate: Date(timeIntervalSince1970: 10), - isFinishedLaunching: true, - isTerminated: false - ) - let newer = CoreMonitorRunningInstance( - processIdentifier: 850, - launchDate: Date(timeIntervalSince1970: 20), - isFinishedLaunching: true, - isTerminated: false - ) - - let target = CoreMonitorSingleInstancePolicy.handoffTarget( - from: [newer, oldest], - currentPID: currentPID - ) - - XCTAssertEqual(target, oldest) + func testDeadLockOwnerIsNotUsedForHandoff() { + let owner = CoreMonitorRunningInstance(processIdentifier: 901, launchDate: nil, isFinishedLaunching: true, isTerminated: true) + XCTAssertNil(CoreMonitorSingleInstancePolicy.handoffTarget(from: [owner], currentPID: 900, ownerPID: 901)) } - func testHandoffTargetFallsBackToPIDWhenLaunchDateIsMissing() { - let currentPID = pid_t(900) - let lowerPID = CoreMonitorRunningInstance( - processIdentifier: 700, - launchDate: nil, - isFinishedLaunching: true, - isTerminated: false - ) - let higherPID = CoreMonitorRunningInstance( - processIdentifier: 750, - launchDate: nil, - isFinishedLaunching: true, - isTerminated: false - ) - - let target = CoreMonitorSingleInstancePolicy.handoffTarget( - from: [higherPID, lowerPID], - currentPID: currentPID - ) - - XCTAssertEqual(target, lowerPID) + func testAcknowledgementMustMatchRequestAndRequestingProcess() { + let request = CoreMonitorDashboardHandoffRequest(bundleIdentifier: "CoreTools.Core-Monitor", targetProcessIdentifier: 901, + requesterProcessIdentifier: 900) + XCTAssertTrue(CoreMonitorDashboardHandoffRequest.acceptsAcknowledgement(userInfo: request.userInfo, + bundleIdentifier: request.bundleIdentifier, requestIdentifier: request.requestIdentifier, requesterPID: 900)) + XCTAssertFalse(CoreMonitorDashboardHandoffRequest.acceptsAcknowledgement(userInfo: request.userInfo, + bundleIdentifier: request.bundleIdentifier, requestIdentifier: UUID(), requesterPID: 900)) + XCTAssertFalse(CoreMonitorDashboardHandoffRequest.acceptsAcknowledgement(userInfo: request.userInfo, + bundleIdentifier: request.bundleIdentifier, requestIdentifier: request.requestIdentifier, requesterPID: 902)) } func testDashboardHandoffRequestRequiresExpectedBundleAndTargetPID() { diff --git a/Core-MonitorTests/DiskProcessSamplerTests.swift b/Core-MonitorTests/DiskProcessSamplerTests.swift index 36d9d80a..776c6c4e 100644 --- a/Core-MonitorTests/DiskProcessSamplerTests.swift +++ b/Core-MonitorTests/DiskProcessSamplerTests.swift @@ -1,6 +1,7 @@ import XCTest @testable import Core_Monitor +@MainActor final class DiskProcessSamplerTests: XCTestCase { func testActivitiesAggregateDeltasByProcessName() { let previousCounters: [Int32: DiskProcessCounter] = [ diff --git a/Core-MonitorTests/FollowupRegressionTests.swift b/Core-MonitorTests/FollowupRegressionTests.swift new file mode 100644 index 00000000..475403e1 --- /dev/null +++ b/Core-MonitorTests/FollowupRegressionTests.swift @@ -0,0 +1,133 @@ +import Combine +import CoreLocation +import Darwin +import XCTest +@testable import Core_Monitor + +@MainActor +final class FollowupRegressionTests: XCTestCase { + func testPrivacyRedactionFromBackgroundPublisherRunsOnMainThread() async throws { + let suite = "CoreMonitor.PrivacyTest.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let privacy = PrivacySettings(defaults: defaults) + privacy.processInsightsEnabled = true + var store = AlertStore.default() + store.desktopNotificationsEnabled = false + store.history = [AlertEvent(id: UUID(), kind: .cpuUsage, severity: .warning, + title: "CPU", message: "Busy", context: "PrivateProcess", timestamp: Date(), isRecovery: false)] + defaults.set(try JSONEncoder().encode(store), forKey: "coremonitor.alertStore.v1") + let monitor = SystemMonitor(privacySettings: privacy) + let manager = AlertManager(systemMonitor: monitor, fanController: FanController(systemMonitor: monitor), + privacySettings: privacy, userDefaults: defaults) + XCTAssertEqual(manager.history.first?.context, "PrivateProcess") + let redacted = expectation(description: "History redacted on main thread") + let subscription = manager.$history.dropFirst().sink { events in + guard events.first?.context == nil else { return } + XCTAssertTrue(Thread.isMainThread) + redacted.fulfill() + } + DispatchQueue.global().async { privacy.processInsightsEnabled = false } + await fulfillment(of: [redacted], timeout: 3) + subscription.cancel() + XCTAssertFalse(manager.processInsightsEnabled) + let persisted = try JSONDecoder().decode(AlertStore.self, from: XCTUnwrap(defaults.data(forKey: "coremonitor.alertStore.v1"))) + XCTAssertNil(persisted.history.first?.context) + } + + func testReusedPIDStartsNewDiskBaselineEvenWhenCountersIncrease() { + let old = DiskProcessCounter(pid: 42, name: "Old", readBytes: 100, writtenBytes: 100, startTime: 1) + let replacement = DiskProcessCounter(pid: 42, name: "New", readBytes: 1_000, writtenBytes: 2_000, startTime: 2) + XCTAssertTrue(DiskProcessSampling.activities(from: [replacement], previousCounters: [42: old], limit: 4).isEmpty) + let later = DiskProcessCounter(pid: 42, name: "New", readBytes: 1_200, writtenBytes: 2_050, startTime: 2) + XCTAssertEqual(DiskProcessSampling.activities(from: [later], previousCounters: [42: replacement], limit: 4), + [DiskProcessActivity(name: "New", readBytes: 200, writtenBytes: 50)]) + } + + func testLockExcludesOtherHandlesAndProcessesAndCanBeReacquired() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("instance.lock") + let first = SingleInstanceLock(fileURL: url) + let second = SingleInstanceLock(fileURL: url) + XCTAssertTrue(try first.acquire()) + XCTAssertFalse(try second.acquire()) + XCTAssertEqual(second.ownerPID, getpid()) + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/python3") + process.arguments = ["-c", """ + import fcntl, sys + with open(sys.argv[1], 'a') as handle: + try: + fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + sys.exit(73) + """, url.path] + try process.run() + process.waitUntilExit() + XCTAssertEqual(process.terminationStatus, 73) + + first.release() + XCTAssertTrue(try second.acquire()) + second.release() + } + + func testLockRejectsSymlinkInsteadOfFollowingIt() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let target = directory.appendingPathComponent("target") + let link = directory.appendingPathComponent("link") + try Data("unchanged".utf8).write(to: target) + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: target) + XCTAssertThrowsError(try SingleInstanceLock(fileURL: link).acquire()) + XCTAssertEqual(try String(contentsOf: target, encoding: .utf8), "unchanged") + } + + func testLocationNameCacheExpiresAndRespectsLocationAndLanguage() async { + let cache = WeatherLocationNameCache(lifetime: 60, distanceThreshold: 500) + let resolver = CountingPlaceResolver() + let nearby = CLLocation(latitude: 59.3293, longitude: 18.0686) + let distant = CLLocation(latitude: 57.7089, longitude: 11.9746) + let time = Date(timeIntervalSince1970: 100) + let resolve: @Sendable (CLLocation, Locale) async throws -> String? = { _, _ in await resolver.next() } + let first = await cache.name(for: nearby, locale: Locale(identifier: "en"), now: time, resolve: resolve) + let cached = await cache.name(for: nearby, locale: Locale(identifier: "en"), now: time.addingTimeInterval(59), resolve: resolve) + XCTAssertEqual(first, cached) + let expired = await cache.name(for: nearby, locale: Locale(identifier: "en"), now: time.addingTimeInterval(60), resolve: resolve) + XCTAssertNotEqual(first, expired) + _ = await cache.name(for: distant, locale: Locale(identifier: "en"), now: time.addingTimeInterval(61), resolve: resolve) + _ = await cache.name(for: distant, locale: Locale(identifier: "sv"), now: time.addingTimeInterval(62), resolve: resolve) + let count = await resolver.count + XCTAssertEqual(count, 4) + } + + func testConcurrentPlaceRequestsShareLookupAndFailuresAreRetried() async { + let cache = WeatherLocationNameCache() + let resolver = CountingPlaceResolver() + let location = CLLocation(latitude: 1, longitude: 1) + let locale = Locale(identifier: "en") + let resolve: @Sendable (CLLocation, Locale) async throws -> String? = { _, _ in + try await Task.sleep(nanoseconds: 20_000_000) + return await resolver.next() + } + async let first = cache.name(for: location, locale: locale, resolve: resolve) + async let second = cache.name(for: location, locale: locale, resolve: resolve) + let values = await (first, second) + XCTAssertEqual(values.0, values.1) + let count = await resolver.count + XCTAssertEqual(count, 1) + + let failing = WeatherLocationNameCache() + let failed = await failing.name(for: location, locale: locale) { _, _ in nil } + XCTAssertEqual(failed, "Weather") + let recovered = await failing.name(for: location, locale: locale) { _, _ in "Recovered" } + XCTAssertEqual(recovered, "Recovered") + } +} + +private actor CountingPlaceResolver { + private(set) var count = 0 + func next() -> String { count += 1; return "Place \(count)" } +} From 0101dca5457237c6e1254d2d8fcdf510375c9024 Mon Sep 17 00:00:00 2001 From: SmokeStudios <290871035+OGSmokeStudios@users.noreply.github.com> Date: Sat, 19 Sep 2026 12:03:20 +0200 Subject: [PATCH 2/4] Complete battery catalog coverage and verify follow-up regressions --- Core-Monitor/BatteryDetailFormatter.swift | 6 +- Core-Monitor/BatteryDetails.xcstrings | 6035 ++++++++++++++++- Core-Monitor/Core_MonitorApp.swift | 1 + Core-Monitor/SingleInstanceLock.swift | 4 +- Core-Monitor/WeatherLocationNameCache.swift | 3 +- .../BatteryDetailFormatterTests.swift | 26 + .../FollowupRegressionTests.swift | 19 +- 7 files changed, 5994 insertions(+), 100 deletions(-) diff --git a/Core-Monitor/BatteryDetailFormatter.swift b/Core-Monitor/BatteryDetailFormatter.swift index bcafe01e..6bc81af2 100644 --- a/Core-Monitor/BatteryDetailFormatter.swift +++ b/Core-Monitor/BatteryDetailFormatter.swift @@ -69,8 +69,8 @@ enum BatteryDetailFormatter { private static func localized(_ key: String, locale: Locale) -> String { let language = Bundle.preferredLocalizations(from: Bundle.main.localizations, forPreferences: [locale.identifier]).first ?? "en" guard let path = Bundle.main.path(forResource: language, ofType: "lproj"), let bundle = Bundle(path: path) else { return key } - let existing = bundle.localizedString(forKey: key, value: key, table: nil) - if existing != key { return existing } - return bundle.localizedString(forKey: key, value: key, table: "BatteryDetails") + let battery = bundle.localizedString(forKey: key, value: key, table: "BatteryDetails") + if battery != key { return battery } + return bundle.localizedString(forKey: key, value: key, table: nil) } } diff --git a/Core-Monitor/BatteryDetails.xcstrings b/Core-Monitor/BatteryDetails.xcstrings index a4cd26c9..ba635820 100644 --- a/Core-Monitor/BatteryDetails.xcstrings +++ b/Core-Monitor/BatteryDetails.xcstrings @@ -33,215 +33,6072 @@ "state": "translated", "value": "Alimentación por batería" } + }, + "af": { + "stringUnit": { + "state": "needs_review", + "value": "Batterykrag" + } + }, + "am": { + "stringUnit": { + "state": "needs_review", + "value": "የባትሪ ኃይል" + } + }, + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "طاقة البطارية" + } + }, + "as": { + "stringUnit": { + "state": "needs_review", + "value": "বেটাৰীৰ শক্তি" + } + }, + "ay": { + "stringUnit": { + "state": "needs_review", + "value": "Batería ch’ama" + } + }, + "az": { + "stringUnit": { + "state": "needs_review", + "value": "Batareya enerjisi" + } + }, + "be": { + "stringUnit": { + "state": "needs_review", + "value": "Жыўленне ад батарэі" + } + }, + "bg": { + "stringUnit": { + "state": "needs_review", + "value": "Захранване от батерията" + } + }, + "bm": { + "stringUnit": { + "state": "needs_review", + "value": "Batiri fanga" + } + }, + "bn": { + "stringUnit": { + "state": "needs_review", + "value": "ব্যাটারির শক্তি" + } + }, + "bo": { + "stringUnit": { + "state": "needs_review", + "value": "གློག་རྫས་ཀྱི་ནུས་ཤུགས།" + } + }, + "bs": { + "stringUnit": { + "state": "needs_review", + "value": "Napajanje iz baterije" + } + }, + "ca": { + "stringUnit": { + "state": "needs_review", + "value": "Alimentació amb bateria" + } + }, + "ceb": { + "stringUnit": { + "state": "needs_review", + "value": "Kuryente sa baterya" + } + }, + "co": { + "stringUnit": { + "state": "needs_review", + "value": "Alimentazione à batteria" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Napájení z baterie" + } + }, + "cy": { + "stringUnit": { + "state": "needs_review", + "value": "Pŵer batri" + } + }, + "da": { + "stringUnit": { + "state": "needs_review", + "value": "Batteristrøm" + } + }, + "dv": { + "stringUnit": { + "state": "needs_review", + "value": "ބެޓެރީގެ ބާރު" + } + }, + "el": { + "stringUnit": { + "state": "needs_review", + "value": "Τροφοδοσία από μπαταρία" + } + }, + "eo": { + "stringUnit": { + "state": "needs_review", + "value": "Bateria energio" + } + }, + "et": { + "stringUnit": { + "state": "needs_review", + "value": "Akutoide" + } + }, + "eu": { + "stringUnit": { + "state": "needs_review", + "value": "Bateriaren energia" + } + }, + "fa": { + "stringUnit": { + "state": "needs_review", + "value": "برق باتری" + } + }, + "fi": { + "stringUnit": { + "state": "needs_review", + "value": "Akkuvirta" + } + }, + "fil": { + "stringUnit": { + "state": "needs_review", + "value": "Kuryente mula sa baterya" + } + }, + "fy": { + "stringUnit": { + "state": "needs_review", + "value": "Batterijstream" + } + }, + "ga": { + "stringUnit": { + "state": "needs_review", + "value": "Cumhacht ceallraí" + } + }, + "gd": { + "stringUnit": { + "state": "needs_review", + "value": "Cumhachd a’ bhataraidh" + } + }, + "gl": { + "stringUnit": { + "state": "needs_review", + "value": "Alimentación por batería" + } + }, + "gn": { + "stringUnit": { + "state": "needs_review", + "value": "Batería mbarete" + } + }, + "gu": { + "stringUnit": { + "state": "needs_review", + "value": "બેટરી પાવર" + } + }, + "ha": { + "stringUnit": { + "state": "needs_review", + "value": "Wutar batiri" + } + }, + "haw": { + "stringUnit": { + "state": "needs_review", + "value": "Mana pākahiko" + } + }, + "he": { + "stringUnit": { + "state": "needs_review", + "value": "מתח סוללה" + } + }, + "hi": { + "stringUnit": { + "state": "needs_review", + "value": "बैटरी पावर" + } + }, + "hmn": { + "stringUnit": { + "state": "needs_review", + "value": "Fais fab roj teeb" + } + }, + "hr": { + "stringUnit": { + "state": "needs_review", + "value": "Napajanje iz baterije" + } + }, + "ht": { + "stringUnit": { + "state": "needs_review", + "value": "Kouran batri" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Akkumulátoros tápellátás" + } + }, + "hy": { + "stringUnit": { + "state": "needs_review", + "value": "Մարտկոցի սնուցում" + } + }, + "id": { + "stringUnit": { + "state": "needs_review", + "value": "Daya baterai" + } + }, + "ig": { + "stringUnit": { + "state": "needs_review", + "value": "Ike batrị" + } + }, + "is": { + "stringUnit": { + "state": "needs_review", + "value": "Rafhlöðuafl" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Alimentazione a batteria" + } + }, + "ja": { + "stringUnit": { + "state": "needs_review", + "value": "バッテリー電源" + } + }, + "jv": { + "stringUnit": { + "state": "needs_review", + "value": "Daya baterei" + } + }, + "ka": { + "stringUnit": { + "state": "needs_review", + "value": "ბატარეის კვება" + } + }, + "kk": { + "stringUnit": { + "state": "needs_review", + "value": "Батареядан қуат алу" + } + }, + "km": { + "stringUnit": { + "state": "needs_review", + "value": "ថាមពលថ្ម" + } + }, + "kn": { + "stringUnit": { + "state": "needs_review", + "value": "ಬ್ಯಾಟರಿ ವಿದ್ಯುತ್" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "배터리 전원" + } + }, + "ku": { + "stringUnit": { + "state": "needs_review", + "value": "Hêza bateriyê" + } + }, + "ky": { + "stringUnit": { + "state": "needs_review", + "value": "Батареядан кубат алуу" + } + }, + "la": { + "stringUnit": { + "state": "needs_review", + "value": "Vis pilae" + } + }, + "lb": { + "stringUnit": { + "state": "needs_review", + "value": "Batteriestroum" + } + }, + "lo": { + "stringUnit": { + "state": "needs_review", + "value": "ພະລັງງານແບັດເຕີຣີ" + } + }, + "lt": { + "stringUnit": { + "state": "needs_review", + "value": "Maitinimas iš akumuliatoriaus" + } + }, + "lv": { + "stringUnit": { + "state": "needs_review", + "value": "Akumulatora barošana" + } + }, + "mg": { + "stringUnit": { + "state": "needs_review", + "value": "Herin’ny bateria" + } + }, + "mi": { + "stringUnit": { + "state": "needs_review", + "value": "Hiko pūhiko" + } + }, + "mk": { + "stringUnit": { + "state": "needs_review", + "value": "Напојување од батеријата" + } + }, + "ml": { + "stringUnit": { + "state": "needs_review", + "value": "ബാറ്ററി വൈദ്യുതി" + } + }, + "mn": { + "stringUnit": { + "state": "needs_review", + "value": "Зайны тэжээл" + } + }, + "mr": { + "stringUnit": { + "state": "needs_review", + "value": "बॅटरी पॉवर" + } + }, + "ms": { + "stringUnit": { + "state": "needs_review", + "value": "Kuasa bateri" + } + }, + "mt": { + "stringUnit": { + "state": "needs_review", + "value": "Enerġija mill-batterija" + } + }, + "my": { + "stringUnit": { + "state": "needs_review", + "value": "ဘက်ထရီပါဝါ" + } + }, + "nb": { + "stringUnit": { + "state": "needs_review", + "value": "Batteristrøm" + } + }, + "ne": { + "stringUnit": { + "state": "needs_review", + "value": "ब्याट्री पावर" + } + }, + "nl": { + "stringUnit": { + "state": "needs_review", + "value": "Batterijvoeding" + } + }, + "ny": { + "stringUnit": { + "state": "needs_review", + "value": "Mphamvu ya batire" + } + }, + "or": { + "stringUnit": { + "state": "needs_review", + "value": "ବ୍ୟାଟେରୀ ଶକ୍ତି" + } + }, + "pa": { + "stringUnit": { + "state": "needs_review", + "value": "ਬੈਟਰੀ ਪਾਵਰ" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Zasilanie z baterii" + } + }, + "ps": { + "stringUnit": { + "state": "needs_review", + "value": "د بیټرۍ برېښنا" + } + }, + "pt": { + "stringUnit": { + "state": "needs_review", + "value": "Energia da bateria" + } + }, + "ro": { + "stringUnit": { + "state": "needs_review", + "value": "Alimentare de la baterie" + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Питание от аккумулятора" + } + }, + "rw": { + "stringUnit": { + "state": "needs_review", + "value": "Ingufu za bateri" + } + }, + "sd": { + "stringUnit": { + "state": "needs_review", + "value": "بيٽري جي بجلي" + } + }, + "si": { + "stringUnit": { + "state": "needs_review", + "value": "බැටරි බලය" + } + }, + "sk": { + "stringUnit": { + "state": "needs_review", + "value": "Napájanie z batérie" + } + }, + "sl": { + "stringUnit": { + "state": "needs_review", + "value": "Baterijsko napajanje" + } + }, + "sm": { + "stringUnit": { + "state": "needs_review", + "value": "Malosiaga o le maa" + } + }, + "sn": { + "stringUnit": { + "state": "needs_review", + "value": "Simba rebhatiri" + } + }, + "so": { + "stringUnit": { + "state": "needs_review", + "value": "Awoodda baytariga" + } + }, + "sq": { + "stringUnit": { + "state": "needs_review", + "value": "Furnizim nga bateria" + } + }, + "sr": { + "stringUnit": { + "state": "needs_review", + "value": "Напајање из батерије" + } + }, + "st": { + "stringUnit": { + "state": "needs_review", + "value": "Matla a betri" + } + }, + "su": { + "stringUnit": { + "state": "needs_review", + "value": "Daya batré" + } + }, + "sw": { + "stringUnit": { + "state": "needs_review", + "value": "Nishati ya betri" + } + }, + "ta": { + "stringUnit": { + "state": "needs_review", + "value": "மின்கல ஆற்றல்" + } + }, + "te": { + "stringUnit": { + "state": "needs_review", + "value": "బ్యాటరీ శక్తి" + } + }, + "tg": { + "stringUnit": { + "state": "needs_review", + "value": "Қувваи батарея" + } + }, + "th": { + "stringUnit": { + "state": "needs_review", + "value": "พลังงานแบตเตอรี่" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Pil gücü" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Живлення від акумулятора" + } + }, + "ur": { + "stringUnit": { + "state": "needs_review", + "value": "بیٹری کی طاقت" + } + }, + "uz": { + "stringUnit": { + "state": "needs_review", + "value": "Batareya quvvati" + } + }, + "vi": { + "stringUnit": { + "state": "needs_review", + "value": "Nguồn pin" + } + }, + "xh": { + "stringUnit": { + "state": "needs_review", + "value": "Amandla ebhetri" + } + }, + "yi": { + "stringUnit": { + "state": "needs_review", + "value": "באַטעריע־מאַכט" + } + }, + "yo": { + "stringUnit": { + "state": "needs_review", + "value": "Agbára bátírì" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "电池供电" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "needs_review", + "value": "電池供電" + } + }, + "zu": { + "stringUnit": { + "state": "needs_review", + "value": "Amandla ebhethri" + } + } + }, + "comment": "Battery power source or runtime. Preserve %@ when present. Added translations require native-speaker review." + }, + "Power Adapter": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Power Adapter" + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Nätadapter" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Netzteil" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Adaptateur secteur" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Adaptador de corriente" + } + }, + "af": { + "stringUnit": { + "state": "needs_review", + "value": "Kragadapter" + } + }, + "am": { + "stringUnit": { + "state": "needs_review", + "value": "የኃይል አስማሚ" + } + }, + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "محوّل الطاقة" + } + }, + "as": { + "stringUnit": { + "state": "needs_review", + "value": "পাৱাৰ এডাপ্টাৰ" + } + }, + "ay": { + "stringUnit": { + "state": "needs_review", + "value": "Ch’ama adaptador" + } + }, + "az": { + "stringUnit": { + "state": "needs_review", + "value": "Enerji adapteri" + } + }, + "be": { + "stringUnit": { + "state": "needs_review", + "value": "Адаптар сілкавання" + } + }, + "bg": { + "stringUnit": { + "state": "needs_review", + "value": "Захранващ адаптер" + } + }, + "bm": { + "stringUnit": { + "state": "needs_review", + "value": "Kuran adaptatɛri" + } + }, + "bn": { + "stringUnit": { + "state": "needs_review", + "value": "পাওয়ার অ্যাডাপ্টার" + } + }, + "bo": { + "stringUnit": { + "state": "needs_review", + "value": "གློག་ཁུངས་མཐུན་སྒྲིག་ཆས།" + } + }, + "bs": { + "stringUnit": { + "state": "needs_review", + "value": "Adapter za napajanje" + } + }, + "ca": { + "stringUnit": { + "state": "needs_review", + "value": "Adaptador de corrent" + } + }, + "ceb": { + "stringUnit": { + "state": "needs_review", + "value": "Adapter sa kuryente" + } + }, + "co": { + "stringUnit": { + "state": "needs_review", + "value": "Adattatore d’alimentazione" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Napájecí adaptér" + } + }, + "cy": { + "stringUnit": { + "state": "needs_review", + "value": "Addasydd pŵer" + } + }, + "da": { + "stringUnit": { + "state": "needs_review", + "value": "Strømforsyning" + } + }, + "dv": { + "stringUnit": { + "state": "needs_review", + "value": "ޕަވަރ އެޑެޕްޓަރ" + } + }, + "el": { + "stringUnit": { + "state": "needs_review", + "value": "Τροφοδοτικό" + } + }, + "eo": { + "stringUnit": { + "state": "needs_review", + "value": "Elektra adaptilo" + } + }, + "et": { + "stringUnit": { + "state": "needs_review", + "value": "Toiteadapter" + } + }, + "eu": { + "stringUnit": { + "state": "needs_review", + "value": "Elikatze-egokigailua" + } + }, + "fa": { + "stringUnit": { + "state": "needs_review", + "value": "آداپتور برق" + } + }, + "fi": { + "stringUnit": { + "state": "needs_review", + "value": "Virtalähde" + } + }, + "fil": { + "stringUnit": { + "state": "needs_review", + "value": "Adaptor ng kuryente" + } + }, + "fy": { + "stringUnit": { + "state": "needs_review", + "value": "Stroomadapter" + } + }, + "ga": { + "stringUnit": { + "state": "needs_review", + "value": "Cuibheoir cumhachta" + } + }, + "gd": { + "stringUnit": { + "state": "needs_review", + "value": "Adaptar cumhachd" + } + }, + "gl": { + "stringUnit": { + "state": "needs_review", + "value": "Adaptador de corrente" + } + }, + "gn": { + "stringUnit": { + "state": "needs_review", + "value": "Tendyry adaptador" + } + }, + "gu": { + "stringUnit": { + "state": "needs_review", + "value": "પાવર ઍડેપ્ટર" + } + }, + "ha": { + "stringUnit": { + "state": "needs_review", + "value": "Adaftar wuta" + } + }, + "haw": { + "stringUnit": { + "state": "needs_review", + "value": "Mea hoʻopili mana" + } + }, + "he": { + "stringUnit": { + "state": "needs_review", + "value": "ספק כוח" + } + }, + "hi": { + "stringUnit": { + "state": "needs_review", + "value": "पावर अडैप्टर" + } + }, + "hmn": { + "stringUnit": { + "state": "needs_review", + "value": "Lub adapter fais fab" + } + }, + "hr": { + "stringUnit": { + "state": "needs_review", + "value": "Adapter za napajanje" + } + }, + "ht": { + "stringUnit": { + "state": "needs_review", + "value": "Adaptè kouran" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Hálózati adapter" + } + }, + "hy": { + "stringUnit": { + "state": "needs_review", + "value": "Սնուցման ադապտեր" + } + }, + "id": { + "stringUnit": { + "state": "needs_review", + "value": "Adaptor daya" + } + }, + "ig": { + "stringUnit": { + "state": "needs_review", + "value": "Ihe nkwụnye ọkụ" + } + }, + "is": { + "stringUnit": { + "state": "needs_review", + "value": "Straumbreytir" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Alimentatore" + } + }, + "ja": { + "stringUnit": { + "state": "needs_review", + "value": "電源アダプタ" + } + }, + "jv": { + "stringUnit": { + "state": "needs_review", + "value": "Adaptor daya" + } + }, + "ka": { + "stringUnit": { + "state": "needs_review", + "value": "კვების ადაპტერი" + } + }, + "kk": { + "stringUnit": { + "state": "needs_review", + "value": "Қуат адаптері" + } + }, + "km": { + "stringUnit": { + "state": "needs_review", + "value": "អាដាប់ទ័រថាមពល" + } + }, + "kn": { + "stringUnit": { + "state": "needs_review", + "value": "ಪವರ್ ಅಡಾಪ್ಟರ್" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "전원 어댑터" + } + }, + "ku": { + "stringUnit": { + "state": "needs_review", + "value": "Adaptera elektrîkê" + } + }, + "ky": { + "stringUnit": { + "state": "needs_review", + "value": "Кубат адаптери" + } + }, + "la": { + "stringUnit": { + "state": "needs_review", + "value": "Adaptor electricus" + } + }, + "lb": { + "stringUnit": { + "state": "needs_review", + "value": "Stroumadapter" + } + }, + "lo": { + "stringUnit": { + "state": "needs_review", + "value": "ອະແດັບເຕີໄຟ" + } + }, + "lt": { + "stringUnit": { + "state": "needs_review", + "value": "Maitinimo adapteris" + } + }, + "lv": { + "stringUnit": { + "state": "needs_review", + "value": "Strāvas adapteris" + } + }, + "mg": { + "stringUnit": { + "state": "needs_review", + "value": "Adaptatera herinaratra" + } + }, + "mi": { + "stringUnit": { + "state": "needs_review", + "value": "Whāurutau hiko" + } + }, + "mk": { + "stringUnit": { + "state": "needs_review", + "value": "Адаптер за напојување" + } + }, + "ml": { + "stringUnit": { + "state": "needs_review", + "value": "പവർ അഡാപ്റ്റർ" + } + }, + "mn": { + "stringUnit": { + "state": "needs_review", + "value": "Тэжээлийн адаптер" + } + }, + "mr": { + "stringUnit": { + "state": "needs_review", + "value": "पॉवर अडॅप्टर" + } + }, + "ms": { + "stringUnit": { + "state": "needs_review", + "value": "Penyesuai kuasa" + } + }, + "mt": { + "stringUnit": { + "state": "needs_review", + "value": "Adapter tal-enerġija" + } + }, + "my": { + "stringUnit": { + "state": "needs_review", + "value": "ပါဝါအဒက်တာ" + } + }, + "nb": { + "stringUnit": { + "state": "needs_review", + "value": "Strømadapter" + } + }, + "ne": { + "stringUnit": { + "state": "needs_review", + "value": "पावर एडाप्टर" + } + }, + "nl": { + "stringUnit": { + "state": "needs_review", + "value": "Voedingsadapter" + } + }, + "ny": { + "stringUnit": { + "state": "needs_review", + "value": "Adaputala yamagetsi" + } + }, + "or": { + "stringUnit": { + "state": "needs_review", + "value": "ପାୱାର ଆଡାପ୍ଟର" + } + }, + "pa": { + "stringUnit": { + "state": "needs_review", + "value": "ਪਾਵਰ ਅਡਾਪਟਰ" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Zasilacz" + } + }, + "ps": { + "stringUnit": { + "state": "needs_review", + "value": "د برېښنا اډاپټر" + } + }, + "pt": { + "stringUnit": { + "state": "needs_review", + "value": "Adaptador de energia" + } + }, + "ro": { + "stringUnit": { + "state": "needs_review", + "value": "Adaptor de alimentare" + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Адаптер питания" + } + }, + "rw": { + "stringUnit": { + "state": "needs_review", + "value": "Adaputeri y’amashanyarazi" + } + }, + "sd": { + "stringUnit": { + "state": "needs_review", + "value": "پاور اڊاپٽر" + } + }, + "si": { + "stringUnit": { + "state": "needs_review", + "value": "බල ඇඩැප්ටරය" + } + }, + "sk": { + "stringUnit": { + "state": "needs_review", + "value": "Napájací adaptér" + } + }, + "sl": { + "stringUnit": { + "state": "needs_review", + "value": "Napajalnik" + } + }, + "sm": { + "stringUnit": { + "state": "needs_review", + "value": "Fetuunaʻi eletise" + } + }, + "sn": { + "stringUnit": { + "state": "needs_review", + "value": "Adaputa yemagetsi" + } + }, + "so": { + "stringUnit": { + "state": "needs_review", + "value": "Adabtarka korontada" + } + }, + "sq": { + "stringUnit": { + "state": "needs_review", + "value": "Përshtatës energjie" + } + }, + "sr": { + "stringUnit": { + "state": "needs_review", + "value": "Адаптер за напајање" + } + }, + "st": { + "stringUnit": { + "state": "needs_review", + "value": "Adaptara ea motlakase" + } + }, + "su": { + "stringUnit": { + "state": "needs_review", + "value": "Adaptor daya" + } + }, + "sw": { + "stringUnit": { + "state": "needs_review", + "value": "Adapta ya umeme" + } + }, + "ta": { + "stringUnit": { + "state": "needs_review", + "value": "மின் அடாப்டர்" + } + }, + "te": { + "stringUnit": { + "state": "needs_review", + "value": "పవర్ అడాప్టర్" + } + }, + "tg": { + "stringUnit": { + "state": "needs_review", + "value": "Адаптери барқ" + } + }, + "th": { + "stringUnit": { + "state": "needs_review", + "value": "อะแดปเตอร์ไฟฟ้า" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Güç adaptörü" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Адаптер живлення" + } + }, + "ur": { + "stringUnit": { + "state": "needs_review", + "value": "پاور اڈاپٹر" + } + }, + "uz": { + "stringUnit": { + "state": "needs_review", + "value": "Quvvat adapteri" + } + }, + "vi": { + "stringUnit": { + "state": "needs_review", + "value": "Bộ tiếp hợp nguồn" + } + }, + "xh": { + "stringUnit": { + "state": "needs_review", + "value": "Iadaptha yombane" + } + }, + "yi": { + "stringUnit": { + "state": "needs_review", + "value": "שטראָם־אַדאַפּטער" + } + }, + "yo": { + "stringUnit": { + "state": "needs_review", + "value": "Adápútà iná" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "电源适配器" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "needs_review", + "value": "電源轉接器" + } + }, + "zu": { + "stringUnit": { + "state": "needs_review", + "value": "I-adaptha yamandla" + } + } + }, + "comment": "Battery power source or runtime. Preserve %@ when present. Added translations require native-speaker review." + }, + "Internal Battery": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Internal Battery" + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Internt batteri" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Interne Batterie" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Batterie interne" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Batería interna" + } + }, + "af": { + "stringUnit": { + "state": "needs_review", + "value": "Interne battery" + } + }, + "am": { + "stringUnit": { + "state": "needs_review", + "value": "ውስጣዊ ባትሪ" + } + }, + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "البطارية الداخلية" + } + }, + "as": { + "stringUnit": { + "state": "needs_review", + "value": "ভিতৰৰ বেটাৰী" + } + }, + "ay": { + "stringUnit": { + "state": "needs_review", + "value": "Manqha batería" + } + }, + "az": { + "stringUnit": { + "state": "needs_review", + "value": "Daxili batareya" + } + }, + "be": { + "stringUnit": { + "state": "needs_review", + "value": "Унутраная батарэя" + } + }, + "bg": { + "stringUnit": { + "state": "needs_review", + "value": "Вътрешна батерия" + } + }, + "bm": { + "stringUnit": { + "state": "needs_review", + "value": "Kɔnɔna batiri" + } + }, + "bn": { + "stringUnit": { + "state": "needs_review", + "value": "অভ্যন্তরীণ ব্যাটারি" + } + }, + "bo": { + "stringUnit": { + "state": "needs_review", + "value": "ནང་གི་གློག་རྫས།" + } + }, + "bs": { + "stringUnit": { + "state": "needs_review", + "value": "Unutrašnja baterija" + } + }, + "ca": { + "stringUnit": { + "state": "needs_review", + "value": "Bateria interna" + } + }, + "ceb": { + "stringUnit": { + "state": "needs_review", + "value": "Sulod nga baterya" + } + }, + "co": { + "stringUnit": { + "state": "needs_review", + "value": "Batteria interna" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Vnitřní baterie" + } + }, + "cy": { + "stringUnit": { + "state": "needs_review", + "value": "Batri mewnol" + } + }, + "da": { + "stringUnit": { + "state": "needs_review", + "value": "Internt batteri" + } + }, + "dv": { + "stringUnit": { + "state": "needs_review", + "value": "އެތެރޭގެ ބެޓެރީ" + } + }, + "el": { + "stringUnit": { + "state": "needs_review", + "value": "Εσωτερική μπαταρία" + } + }, + "eo": { + "stringUnit": { + "state": "needs_review", + "value": "Interna baterio" + } + }, + "et": { + "stringUnit": { + "state": "needs_review", + "value": "Sisemine aku" + } + }, + "eu": { + "stringUnit": { + "state": "needs_review", + "value": "Barneko bateria" + } + }, + "fa": { + "stringUnit": { + "state": "needs_review", + "value": "باتری داخلی" + } + }, + "fi": { + "stringUnit": { + "state": "needs_review", + "value": "Sisäinen akku" + } + }, + "fil": { + "stringUnit": { + "state": "needs_review", + "value": "Panloob na baterya" + } + }, + "fy": { + "stringUnit": { + "state": "needs_review", + "value": "Ynterne batterij" + } + }, + "ga": { + "stringUnit": { + "state": "needs_review", + "value": "Ceallra inmheánach" + } + }, + "gd": { + "stringUnit": { + "state": "needs_review", + "value": "Bataraidh a-staigh" + } + }, + "gl": { + "stringUnit": { + "state": "needs_review", + "value": "Batería interna" + } + }, + "gn": { + "stringUnit": { + "state": "needs_review", + "value": "Batería hyepypegua" + } + }, + "gu": { + "stringUnit": { + "state": "needs_review", + "value": "આંતરિક બેટરી" + } + }, + "ha": { + "stringUnit": { + "state": "needs_review", + "value": "Batirin ciki" + } + }, + "haw": { + "stringUnit": { + "state": "needs_review", + "value": "Pākahiko kūloko" + } + }, + "he": { + "stringUnit": { + "state": "needs_review", + "value": "סוללה פנימית" + } + }, + "hi": { + "stringUnit": { + "state": "needs_review", + "value": "आंतरिक बैटरी" + } + }, + "hmn": { + "stringUnit": { + "state": "needs_review", + "value": "Roj teeb sab hauv" + } + }, + "hr": { + "stringUnit": { + "state": "needs_review", + "value": "Unutarnja baterija" + } + }, + "ht": { + "stringUnit": { + "state": "needs_review", + "value": "Batri entèn" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Belső akkumulátor" + } + }, + "hy": { + "stringUnit": { + "state": "needs_review", + "value": "Ներքին մարտկոց" + } + }, + "id": { + "stringUnit": { + "state": "needs_review", + "value": "Baterai internal" + } + }, + "ig": { + "stringUnit": { + "state": "needs_review", + "value": "Batrị dị n’ime" + } + }, + "is": { + "stringUnit": { + "state": "needs_review", + "value": "Innri rafhlaða" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Batteria interna" + } + }, + "ja": { + "stringUnit": { + "state": "needs_review", + "value": "内蔵バッテリー" + } + }, + "jv": { + "stringUnit": { + "state": "needs_review", + "value": "Baterei internal" + } + }, + "ka": { + "stringUnit": { + "state": "needs_review", + "value": "შიდა ბატარეა" + } + }, + "kk": { + "stringUnit": { + "state": "needs_review", + "value": "Ішкі батарея" + } + }, + "km": { + "stringUnit": { + "state": "needs_review", + "value": "ថ្មខាងក្នុង" + } + }, + "kn": { + "stringUnit": { + "state": "needs_review", + "value": "ಆಂತರಿಕ ಬ್ಯಾಟರಿ" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "내장 배터리" + } + }, + "ku": { + "stringUnit": { + "state": "needs_review", + "value": "Bateriya hundirîn" + } + }, + "ky": { + "stringUnit": { + "state": "needs_review", + "value": "Ички батарея" + } + }, + "la": { + "stringUnit": { + "state": "needs_review", + "value": "Pila interna" + } + }, + "lb": { + "stringUnit": { + "state": "needs_review", + "value": "Intern Batterie" + } + }, + "lo": { + "stringUnit": { + "state": "needs_review", + "value": "ແບັດເຕີຣີພາຍໃນ" + } + }, + "lt": { + "stringUnit": { + "state": "needs_review", + "value": "Vidinis akumuliatorius" + } + }, + "lv": { + "stringUnit": { + "state": "needs_review", + "value": "Iekšējais akumulators" + } + }, + "mg": { + "stringUnit": { + "state": "needs_review", + "value": "Bateria anatiny" + } + }, + "mi": { + "stringUnit": { + "state": "needs_review", + "value": "Pūhiko ā-roto" + } + }, + "mk": { + "stringUnit": { + "state": "needs_review", + "value": "Внатрешна батерија" + } + }, + "ml": { + "stringUnit": { + "state": "needs_review", + "value": "ആന്തരിക ബാറ്ററി" + } + }, + "mn": { + "stringUnit": { + "state": "needs_review", + "value": "Дотоод зай" + } + }, + "mr": { + "stringUnit": { + "state": "needs_review", + "value": "अंतर्गत बॅटरी" + } + }, + "ms": { + "stringUnit": { + "state": "needs_review", + "value": "Bateri dalaman" + } + }, + "mt": { + "stringUnit": { + "state": "needs_review", + "value": "Batterija interna" + } + }, + "my": { + "stringUnit": { + "state": "needs_review", + "value": "အတွင်းဘက်ထရီ" + } + }, + "nb": { + "stringUnit": { + "state": "needs_review", + "value": "Internt batteri" + } + }, + "ne": { + "stringUnit": { + "state": "needs_review", + "value": "आन्तरिक ब्याट्री" + } + }, + "nl": { + "stringUnit": { + "state": "needs_review", + "value": "Interne batterij" + } + }, + "ny": { + "stringUnit": { + "state": "needs_review", + "value": "Batire yamkati" + } + }, + "or": { + "stringUnit": { + "state": "needs_review", + "value": "ଭିତର ବ୍ୟାଟେରୀ" + } + }, + "pa": { + "stringUnit": { + "state": "needs_review", + "value": "ਅੰਦਰੂਨੀ ਬੈਟਰੀ" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Bateria wewnętrzna" + } + }, + "ps": { + "stringUnit": { + "state": "needs_review", + "value": "دنننۍ بیټرۍ" + } + }, + "pt": { + "stringUnit": { + "state": "needs_review", + "value": "Bateria interna" + } + }, + "ro": { + "stringUnit": { + "state": "needs_review", + "value": "Baterie internă" + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Встроенный аккумулятор" + } + }, + "rw": { + "stringUnit": { + "state": "needs_review", + "value": "Bateri y’imbere" + } + }, + "sd": { + "stringUnit": { + "state": "needs_review", + "value": "اندروني بيٽري" + } + }, + "si": { + "stringUnit": { + "state": "needs_review", + "value": "අභ්‍යන්තර බැටරිය" + } + }, + "sk": { + "stringUnit": { + "state": "needs_review", + "value": "Vnútorná batéria" + } + }, + "sl": { + "stringUnit": { + "state": "needs_review", + "value": "Notranja baterija" + } + }, + "sm": { + "stringUnit": { + "state": "needs_review", + "value": "Maa i totonu" + } + }, + "sn": { + "stringUnit": { + "state": "needs_review", + "value": "Bhatiri remukati" + } + }, + "so": { + "stringUnit": { + "state": "needs_review", + "value": "Baytariga gudaha" + } + }, + "sq": { + "stringUnit": { + "state": "needs_review", + "value": "Bateri e brendshme" + } + }, + "sr": { + "stringUnit": { + "state": "needs_review", + "value": "Унутрашња батерија" + } + }, + "st": { + "stringUnit": { + "state": "needs_review", + "value": "Betri ea ka hare" + } + }, + "su": { + "stringUnit": { + "state": "needs_review", + "value": "Batré internal" + } + }, + "sw": { + "stringUnit": { + "state": "needs_review", + "value": "Betri ya ndani" + } + }, + "ta": { + "stringUnit": { + "state": "needs_review", + "value": "உள்ளமைந்த மின்கலம்" + } + }, + "te": { + "stringUnit": { + "state": "needs_review", + "value": "అంతర్గత బ్యాటరీ" + } + }, + "tg": { + "stringUnit": { + "state": "needs_review", + "value": "Батареяи дохилӣ" + } + }, + "th": { + "stringUnit": { + "state": "needs_review", + "value": "แบตเตอรี่ภายใน" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Dahili pil" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Вбудований акумулятор" + } + }, + "ur": { + "stringUnit": { + "state": "needs_review", + "value": "اندرونی بیٹری" + } + }, + "uz": { + "stringUnit": { + "state": "needs_review", + "value": "Ichki batareya" + } + }, + "vi": { + "stringUnit": { + "state": "needs_review", + "value": "Pin bên trong" + } + }, + "xh": { + "stringUnit": { + "state": "needs_review", + "value": "Ibhetri yangaphakathi" + } + }, + "yi": { + "stringUnit": { + "state": "needs_review", + "value": "אינערלעכע באַטעריע" + } + }, + "yo": { + "stringUnit": { + "state": "needs_review", + "value": "Bátírì inú" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "内置电池" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "needs_review", + "value": "內建電池" + } + }, + "zu": { + "stringUnit": { + "state": "needs_review", + "value": "Ibhethri langaphakathi" + } + } + }, + "comment": "Battery power source or runtime. Preserve %@ when present. Added translations require native-speaker review." + }, + "Finishing soon": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Finishing soon" + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Snart klart" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bald abgeschlossen" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Bientôt terminé" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Finaliza pronto" + } + }, + "af": { + "stringUnit": { + "state": "needs_review", + "value": "Amper klaar" + } + }, + "am": { + "stringUnit": { + "state": "needs_review", + "value": "በቅርቡ ይጠናቀቃል" + } + }, + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "يكتمل قريبًا" + } + }, + "as": { + "stringUnit": { + "state": "needs_review", + "value": "অতি সোনকালে সম্পূৰ্ণ হ’ব" + } + }, + "ay": { + "stringUnit": { + "state": "needs_review", + "value": "Niya tukuyani" + } + }, + "az": { + "stringUnit": { + "state": "needs_review", + "value": "Tezliklə tamamlanacaq" + } + }, + "be": { + "stringUnit": { + "state": "needs_review", + "value": "Хутка скончыцца" + } + }, + "bg": { + "stringUnit": { + "state": "needs_review", + "value": "Скоро ще завърши" + } + }, + "bm": { + "stringUnit": { + "state": "needs_review", + "value": "A bɛ ban sɔɔni" + } + }, + "bn": { + "stringUnit": { + "state": "needs_review", + "value": "শীঘ্রই সম্পূর্ণ হবে" + } + }, + "bo": { + "stringUnit": { + "state": "needs_review", + "value": "མགྱོགས་པོར་འགྲུབ་རྒྱུ།" + } + }, + "bs": { + "stringUnit": { + "state": "needs_review", + "value": "Uskoro završava" + } + }, + "ca": { + "stringUnit": { + "state": "needs_review", + "value": "Acabarà aviat" + } + }, + "ceb": { + "stringUnit": { + "state": "needs_review", + "value": "Hapit na mahuman" + } + }, + "co": { + "stringUnit": { + "state": "needs_review", + "value": "Finisce prestu" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Brzy bude dokončeno" + } + }, + "cy": { + "stringUnit": { + "state": "needs_review", + "value": "Yn gorffen yn fuan" + } + }, + "da": { + "stringUnit": { + "state": "needs_review", + "value": "Snart færdig" + } + }, + "dv": { + "stringUnit": { + "state": "needs_review", + "value": "އަވަހަށް ނިމޭނެ" + } + }, + "el": { + "stringUnit": { + "state": "needs_review", + "value": "Ολοκληρώνεται σύντομα" + } + }, + "eo": { + "stringUnit": { + "state": "needs_review", + "value": "Baldaŭ finiĝos" + } + }, + "et": { + "stringUnit": { + "state": "needs_review", + "value": "Varsti valmis" + } + }, + "eu": { + "stringUnit": { + "state": "needs_review", + "value": "Laster amaituko da" + } + }, + "fa": { + "stringUnit": { + "state": "needs_review", + "value": "به‌زودی تمام می‌شود" + } + }, + "fi": { + "stringUnit": { + "state": "needs_review", + "value": "Valmis pian" + } + }, + "fil": { + "stringUnit": { + "state": "needs_review", + "value": "Malapit nang matapos" + } + }, + "fy": { + "stringUnit": { + "state": "needs_review", + "value": "Hast klear" + } + }, + "ga": { + "stringUnit": { + "state": "needs_review", + "value": "Beagnach críochnaithe" + } + }, + "gd": { + "stringUnit": { + "state": "needs_review", + "value": "Cha mhòr deiseil" + } + }, + "gl": { + "stringUnit": { + "state": "needs_review", + "value": "Rematará axiña" + } + }, + "gn": { + "stringUnit": { + "state": "needs_review", + "value": "Opáta pya’e" + } + }, + "gu": { + "stringUnit": { + "state": "needs_review", + "value": "ટૂંક સમયમાં પૂર્ણ થશે" + } + }, + "ha": { + "stringUnit": { + "state": "needs_review", + "value": "Zai ƙare nan ba da jimawa ba" + } + }, + "haw": { + "stringUnit": { + "state": "needs_review", + "value": "Kokoke e pau" + } + }, + "he": { + "stringUnit": { + "state": "needs_review", + "value": "מסתיים בקרוב" + } + }, + "hi": { + "stringUnit": { + "state": "needs_review", + "value": "जल्द पूरा होगा" + } + }, + "hmn": { + "stringUnit": { + "state": "needs_review", + "value": "Yuav tiav sai sai" + } + }, + "hr": { + "stringUnit": { + "state": "needs_review", + "value": "Uskoro završava" + } + }, + "ht": { + "stringUnit": { + "state": "needs_review", + "value": "Prèske fini" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Hamarosan kész" + } + }, + "hy": { + "stringUnit": { + "state": "needs_review", + "value": "Շուտով կավարտվի" + } + }, + "id": { + "stringUnit": { + "state": "needs_review", + "value": "Segera selesai" + } + }, + "ig": { + "stringUnit": { + "state": "needs_review", + "value": "Ọ ga-agwụ n’oge na-adịghị anya" + } + }, + "is": { + "stringUnit": { + "state": "needs_review", + "value": "Lýkur bráðum" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Completamento imminente" + } + }, + "ja": { + "stringUnit": { + "state": "needs_review", + "value": "まもなく完了" + } + }, + "jv": { + "stringUnit": { + "state": "needs_review", + "value": "Sedhela maneh rampung" + } + }, + "ka": { + "stringUnit": { + "state": "needs_review", + "value": "მალე დასრულდება" + } + }, + "kk": { + "stringUnit": { + "state": "needs_review", + "value": "Жақында аяқталады" + } + }, + "km": { + "stringUnit": { + "state": "needs_review", + "value": "នឹងបញ្ចប់ក្នុងពេលឆាប់ៗ" + } + }, + "kn": { + "stringUnit": { + "state": "needs_review", + "value": "ಶೀಘ್ರದಲ್ಲೇ ಪೂರ್ಣಗೊಳ್ಳುತ್ತದೆ" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "곧 완료됨" + } + }, + "ku": { + "stringUnit": { + "state": "needs_review", + "value": "Dê di demeke nêz de biqede" + } + }, + "ky": { + "stringUnit": { + "state": "needs_review", + "value": "Жакында аяктайт" + } + }, + "la": { + "stringUnit": { + "state": "needs_review", + "value": "Mox perficietur" + } + }, + "lb": { + "stringUnit": { + "state": "needs_review", + "value": "Bal fäerdeg" + } + }, + "lo": { + "stringUnit": { + "state": "needs_review", + "value": "ຈະສຳເລັດໃນໄວໆນີ້" + } + }, + "lt": { + "stringUnit": { + "state": "needs_review", + "value": "Netrukus bus baigta" + } + }, + "lv": { + "stringUnit": { + "state": "needs_review", + "value": "Drīzumā būs pabeigts" + } + }, + "mg": { + "stringUnit": { + "state": "needs_review", + "value": "Ho vita tsy ho ela" + } + }, + "mi": { + "stringUnit": { + "state": "needs_review", + "value": "Ka mutu ākuanei" + } + }, + "mk": { + "stringUnit": { + "state": "needs_review", + "value": "Наскоро ќе заврши" + } + }, + "ml": { + "stringUnit": { + "state": "needs_review", + "value": "ഉടൻ പൂർത്തിയാകും" + } + }, + "mn": { + "stringUnit": { + "state": "needs_review", + "value": "Удахгүй дуусна" + } + }, + "mr": { + "stringUnit": { + "state": "needs_review", + "value": "लवकरच पूर्ण होईल" + } + }, + "ms": { + "stringUnit": { + "state": "needs_review", + "value": "Hampir selesai" + } + }, + "mt": { + "stringUnit": { + "state": "needs_review", + "value": "Se jintemm dalwaqt" + } + }, + "my": { + "stringUnit": { + "state": "needs_review", + "value": "မကြာမီ ပြီးဆုံးမည်" + } + }, + "nb": { + "stringUnit": { + "state": "needs_review", + "value": "Snart ferdig" + } + }, + "ne": { + "stringUnit": { + "state": "needs_review", + "value": "चाँडै सकिँदैछ" + } + }, + "nl": { + "stringUnit": { + "state": "needs_review", + "value": "Bijna klaar" + } + }, + "ny": { + "stringUnit": { + "state": "needs_review", + "value": "Zitha posachedwa" + } + }, + "or": { + "stringUnit": { + "state": "needs_review", + "value": "ଶୀଘ୍ର ସମ୍ପୂର୍ଣ୍ଣ ହେବ" + } + }, + "pa": { + "stringUnit": { + "state": "needs_review", + "value": "ਜਲਦੀ ਪੂਰਾ ਹੋਵੇਗਾ" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Wkrótce zakończenie" + } + }, + "ps": { + "stringUnit": { + "state": "needs_review", + "value": "ژر به بشپړ شي" + } + }, + "pt": { + "stringUnit": { + "state": "needs_review", + "value": "A terminar em breve" + } + }, + "ro": { + "stringUnit": { + "state": "needs_review", + "value": "Se termină în curând" + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Скоро завершится" + } + }, + "rw": { + "stringUnit": { + "state": "needs_review", + "value": "Birarangira vuba" + } + }, + "sd": { + "stringUnit": { + "state": "needs_review", + "value": "جلد مڪمل ٿيندو" + } + }, + "si": { + "stringUnit": { + "state": "needs_review", + "value": "ඉක්මනින් අවසන් වේ" + } + }, + "sk": { + "stringUnit": { + "state": "needs_review", + "value": "Čoskoro sa dokončí" + } + }, + "sl": { + "stringUnit": { + "state": "needs_review", + "value": "Kmalu bo končano" + } + }, + "sm": { + "stringUnit": { + "state": "needs_review", + "value": "Ua toeitiiti maeʻa" + } + }, + "sn": { + "stringUnit": { + "state": "needs_review", + "value": "Zvichapera munguva pfupi" + } + }, + "so": { + "stringUnit": { + "state": "needs_review", + "value": "Dhawaan ayuu dhammaanayaa" + } + }, + "sq": { + "stringUnit": { + "state": "needs_review", + "value": "Përfundon së shpejti" + } + }, + "sr": { + "stringUnit": { + "state": "needs_review", + "value": "Ускоро ће бити завршено" + } + }, + "st": { + "stringUnit": { + "state": "needs_review", + "value": "E tla fela haufinyane" + } + }, + "su": { + "stringUnit": { + "state": "needs_review", + "value": "Geura réngsé" + } + }, + "sw": { + "stringUnit": { + "state": "needs_review", + "value": "Inakamilika hivi karibuni" + } + }, + "ta": { + "stringUnit": { + "state": "needs_review", + "value": "விரைவில் முடிவடையும்" + } + }, + "te": { + "stringUnit": { + "state": "needs_review", + "value": "త్వరలో పూర్తవుతుంది" + } + }, + "tg": { + "stringUnit": { + "state": "needs_review", + "value": "Ба зудӣ анҷом меёбад" + } + }, + "th": { + "stringUnit": { + "state": "needs_review", + "value": "ใกล้เสร็จแล้ว" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Yakında tamamlanacak" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Незабаром завершиться" + } + }, + "ur": { + "stringUnit": { + "state": "needs_review", + "value": "جلد مکمل ہو جائے گا" + } + }, + "uz": { + "stringUnit": { + "state": "needs_review", + "value": "Tez orada tugaydi" + } + }, + "vi": { + "stringUnit": { + "state": "needs_review", + "value": "Sắp hoàn tất" + } + }, + "xh": { + "stringUnit": { + "state": "needs_review", + "value": "Iza kugqiba kungekudala" + } + }, + "yi": { + "stringUnit": { + "state": "needs_review", + "value": "וועט באַלד ענדיקן" + } + }, + "yo": { + "stringUnit": { + "state": "needs_review", + "value": "Ó máa parí láìpẹ́" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "即将完成" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "needs_review", + "value": "即將完成" + } + }, + "zu": { + "stringUnit": { + "state": "needs_review", + "value": "Kuzophela maduze" + } + } + }, + "comment": "Battery power source or runtime. Preserve %@ when present. Added translations require native-speaker review." + }, + "Less than 1m remaining": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Less than 1m remaining" + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Mindre än 1 min kvar" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Weniger als 1 Min. verbleibend" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Moins de 1 min restante" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Menos de 1 min restante" + } + }, + "af": { + "stringUnit": { + "state": "needs_review", + "value": "Minder as 1 minuut oor" + } + }, + "am": { + "stringUnit": { + "state": "needs_review", + "value": "ከ1 ደቂቃ ያነሰ ቀርቷል" + } + }, + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "أقل من دقيقة متبقية" + } + }, + "as": { + "stringUnit": { + "state": "needs_review", + "value": "১ মিনিটতকৈ কম বাকী" + } + }, + "ay": { + "stringUnit": { + "state": "needs_review", + "value": "1 minutot juk’aki qhiparaski" + } + }, + "az": { + "stringUnit": { + "state": "needs_review", + "value": "1 dəqiqədən az qalıb" + } + }, + "be": { + "stringUnit": { + "state": "needs_review", + "value": "Засталося менш за 1 хвіліну" + } + }, + "bg": { + "stringUnit": { + "state": "needs_review", + "value": "Остава по-малко от 1 минута" + } + }, + "bm": { + "stringUnit": { + "state": "needs_review", + "value": "Miniti 1 ka dɔgɔ tora" + } + }, + "bn": { + "stringUnit": { + "state": "needs_review", + "value": "১ মিনিটের কম বাকি" + } + }, + "bo": { + "stringUnit": { + "state": "needs_review", + "value": "སྐར་མ་༡་ལས་ཉུང་བ་ལྷག་ཡོད།" + } + }, + "bs": { + "stringUnit": { + "state": "needs_review", + "value": "Preostalo je manje od 1 minute" + } + }, + "ca": { + "stringUnit": { + "state": "needs_review", + "value": "Queda menys d’1 minut" + } + }, + "ceb": { + "stringUnit": { + "state": "needs_review", + "value": "Ubos sa 1 minuto ang nahibilin" + } + }, + "co": { + "stringUnit": { + "state": "needs_review", + "value": "Ferma menu d’un minutu" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Zbývá méně než 1 minuta" + } + }, + "cy": { + "stringUnit": { + "state": "needs_review", + "value": "Llai nag 1 munud yn weddill" + } + }, + "da": { + "stringUnit": { + "state": "needs_review", + "value": "Under 1 minut tilbage" + } + }, + "dv": { + "stringUnit": { + "state": "needs_review", + "value": "1 މިނިޓަށްވުރެ މަދު ވަގުތު ބާކީ" + } + }, + "el": { + "stringUnit": { + "state": "needs_review", + "value": "Απομένει λιγότερο από 1 λεπτό" + } + }, + "eo": { + "stringUnit": { + "state": "needs_review", + "value": "Restas malpli ol 1 minuto" + } + }, + "et": { + "stringUnit": { + "state": "needs_review", + "value": "Jäänud vähem kui 1 minut" + } + }, + "eu": { + "stringUnit": { + "state": "needs_review", + "value": "Minutu 1 baino gutxiago falta da" + } + }, + "fa": { + "stringUnit": { + "state": "needs_review", + "value": "کمتر از ۱ دقیقه باقی مانده" + } + }, + "fi": { + "stringUnit": { + "state": "needs_review", + "value": "Alle 1 minuutti jäljellä" + } + }, + "fil": { + "stringUnit": { + "state": "needs_review", + "value": "Wala pang 1 minuto ang natitira" + } + }, + "fy": { + "stringUnit": { + "state": "needs_review", + "value": "Minder as 1 minút oer" + } + }, + "ga": { + "stringUnit": { + "state": "needs_review", + "value": "Níos lú ná 1 nóiméad fágtha" + } + }, + "gd": { + "stringUnit": { + "state": "needs_review", + "value": "Nas lugha na 1 mhionaid air fhàgail" + } + }, + "gl": { + "stringUnit": { + "state": "needs_review", + "value": "Queda menos de 1 minuto" + } + }, + "gn": { + "stringUnit": { + "state": "needs_review", + "value": "Sa’ive 1 aravo’i hembýva" + } + }, + "gu": { + "stringUnit": { + "state": "needs_review", + "value": "1 મિનિટથી ઓછો સમય બાકી" + } + }, + "ha": { + "stringUnit": { + "state": "needs_review", + "value": "Ƙasa da minti 1 ya rage" + } + }, + "haw": { + "stringUnit": { + "state": "needs_review", + "value": "Emi iho ma mua o 1 minuke i koe" + } + }, + "he": { + "stringUnit": { + "state": "needs_review", + "value": "נותרה פחות מדקה" + } + }, + "hi": { + "stringUnit": { + "state": "needs_review", + "value": "1 मिनट से कम शेष" + } + }, + "hmn": { + "stringUnit": { + "state": "needs_review", + "value": "Tshuav tsawg dua 1 feeb" + } + }, + "hr": { + "stringUnit": { + "state": "needs_review", + "value": "Preostalo je manje od 1 minute" + } + }, + "ht": { + "stringUnit": { + "state": "needs_review", + "value": "Mwens pase 1 minit rete" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Kevesebb mint 1 perc van hátra" + } + }, + "hy": { + "stringUnit": { + "state": "needs_review", + "value": "Մնացել է 1 րոպեից պակաս" + } + }, + "id": { + "stringUnit": { + "state": "needs_review", + "value": "Tersisa kurang dari 1 menit" + } + }, + "ig": { + "stringUnit": { + "state": "needs_review", + "value": "Ihe na-erughị otu nkeji fọdụrụ" + } + }, + "is": { + "stringUnit": { + "state": "needs_review", + "value": "Minna en 1 mínúta eftir" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Meno di 1 minuto rimanente" + } + }, + "ja": { + "stringUnit": { + "state": "needs_review", + "value": "残り1分未満" + } + }, + "jv": { + "stringUnit": { + "state": "needs_review", + "value": "Isih kurang saka 1 menit" + } + }, + "ka": { + "stringUnit": { + "state": "needs_review", + "value": "დარჩენილია 1 წუთზე ნაკლები" + } + }, + "kk": { + "stringUnit": { + "state": "needs_review", + "value": "1 минуттан аз қалды" + } + }, + "km": { + "stringUnit": { + "state": "needs_review", + "value": "នៅសល់តិចជាង 1 នាទី" + } + }, + "kn": { + "stringUnit": { + "state": "needs_review", + "value": "1 ನಿಮಿಷಕ್ಕಿಂತ ಕಡಿಮೆ ಬಾಕಿ" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "1분 미만 남음" + } + }, + "ku": { + "stringUnit": { + "state": "needs_review", + "value": "Ji 1 deqeyê kêmtir maye" + } + }, + "ky": { + "stringUnit": { + "state": "needs_review", + "value": "1 мүнөттөн аз калды" + } + }, + "la": { + "stringUnit": { + "state": "needs_review", + "value": "Minus quam 1 minutum restat" + } + }, + "lb": { + "stringUnit": { + "state": "needs_review", + "value": "Manner wéi 1 Minutt iwwreg" + } + }, + "lo": { + "stringUnit": { + "state": "needs_review", + "value": "ເຫຼືອໜ້ອຍກວ່າ 1 ນາທີ" + } + }, + "lt": { + "stringUnit": { + "state": "needs_review", + "value": "Liko mažiau nei 1 minutė" + } + }, + "lv": { + "stringUnit": { + "state": "needs_review", + "value": "Atlicis mazāk par 1 minūti" + } + }, + "mg": { + "stringUnit": { + "state": "needs_review", + "value": "Latsaky ny 1 minitra sisa" + } + }, + "mi": { + "stringUnit": { + "state": "needs_review", + "value": "He iti iho i te 1 meneti e toe ana" + } + }, + "mk": { + "stringUnit": { + "state": "needs_review", + "value": "Преостанува помалку од 1 минута" + } + }, + "ml": { + "stringUnit": { + "state": "needs_review", + "value": "1 മിനിറ്റിൽ താഴെ ബാക്കി" + } + }, + "mn": { + "stringUnit": { + "state": "needs_review", + "value": "1 минутаас бага хугацаа үлдсэн" + } + }, + "mr": { + "stringUnit": { + "state": "needs_review", + "value": "1 मिनिटापेक्षा कमी वेळ शिल्लक" + } + }, + "ms": { + "stringUnit": { + "state": "needs_review", + "value": "Kurang daripada 1 minit lagi" + } + }, + "mt": { + "stringUnit": { + "state": "needs_review", + "value": "Fadal inqas minn minuta" + } + }, + "my": { + "stringUnit": { + "state": "needs_review", + "value": "1 မိနစ်အောက် ကျန်ရှိသည်" + } + }, + "nb": { + "stringUnit": { + "state": "needs_review", + "value": "Under 1 minutt igjen" + } + }, + "ne": { + "stringUnit": { + "state": "needs_review", + "value": "१ मिनेटभन्दा कम बाँकी" + } + }, + "nl": { + "stringUnit": { + "state": "needs_review", + "value": "Minder dan 1 minuut resterend" + } + }, + "ny": { + "stringUnit": { + "state": "needs_review", + "value": "Kwatsala mphindi yosakwana 1" + } + }, + "or": { + "stringUnit": { + "state": "needs_review", + "value": "1 ମିନିଟରୁ କମ୍ ବାକି" + } + }, + "pa": { + "stringUnit": { + "state": "needs_review", + "value": "1 ਮਿੰਟ ਤੋਂ ਘੱਟ ਬਾਕੀ" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Pozostała mniej niż 1 minuta" + } + }, + "ps": { + "stringUnit": { + "state": "needs_review", + "value": "له ۱ دقیقې کم وخت پاتې دی" + } + }, + "pt": { + "stringUnit": { + "state": "needs_review", + "value": "Menos de 1 minuto restante" + } + }, + "ro": { + "stringUnit": { + "state": "needs_review", + "value": "Mai puțin de 1 minut rămas" + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Осталось меньше 1 минуты" + } + }, + "rw": { + "stringUnit": { + "state": "needs_review", + "value": "Hasigaye munsi y’umunota 1" + } + }, + "sd": { + "stringUnit": { + "state": "needs_review", + "value": "1 منٽ کان گهٽ باقي" + } + }, + "si": { + "stringUnit": { + "state": "needs_review", + "value": "මිනිත්තුවකට අඩු කාලයක් ඉතිරිව ඇත" + } + }, + "sk": { + "stringUnit": { + "state": "needs_review", + "value": "Zostáva menej ako 1 minúta" + } + }, + "sl": { + "stringUnit": { + "state": "needs_review", + "value": "Preostala je manj kot 1 minuta" + } + }, + "sm": { + "stringUnit": { + "state": "needs_review", + "value": "E itiiti ifo i le 1 minute o totoe" + } + }, + "sn": { + "stringUnit": { + "state": "needs_review", + "value": "Pasara isingasviki miniti 1" + } + }, + "so": { + "stringUnit": { + "state": "needs_review", + "value": "Wax ka yar 1 daqiiqo ayaa hadhay" + } + }, + "sq": { + "stringUnit": { + "state": "needs_review", + "value": "Mbetet më pak se 1 minutë" + } + }, + "sr": { + "stringUnit": { + "state": "needs_review", + "value": "Преостало је мање од 1 минута" + } + }, + "st": { + "stringUnit": { + "state": "needs_review", + "value": "Ho setse ka tlase ho motsotso o le mong" + } + }, + "su": { + "stringUnit": { + "state": "needs_review", + "value": "Kurang ti 1 menit deui" + } + }, + "sw": { + "stringUnit": { + "state": "needs_review", + "value": "Chini ya dakika 1 imesalia" + } + }, + "ta": { + "stringUnit": { + "state": "needs_review", + "value": "1 நிமிடத்திற்கும் குறைவாக மீதமுள்ளது" + } + }, + "te": { + "stringUnit": { + "state": "needs_review", + "value": "1 నిమిషం కంటే తక్కువ మిగిలి ఉంది" + } + }, + "tg": { + "stringUnit": { + "state": "needs_review", + "value": "Камтар аз 1 дақиқа мондааст" + } + }, + "th": { + "stringUnit": { + "state": "needs_review", + "value": "เหลือไม่ถึง 1 นาที" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "1 dakikadan az kaldı" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Залишилося менше ніж 1 хвилина" + } + }, + "ur": { + "stringUnit": { + "state": "needs_review", + "value": "1 منٹ سے کم باقی" + } + }, + "uz": { + "stringUnit": { + "state": "needs_review", + "value": "1 daqiqadan kam qoldi" + } + }, + "vi": { + "stringUnit": { + "state": "needs_review", + "value": "Còn dưới 1 phút" + } + }, + "xh": { + "stringUnit": { + "state": "needs_review", + "value": "Kushiyeke ngaphantsi komzuzu omnye" + } + }, + "yi": { + "stringUnit": { + "state": "needs_review", + "value": "ווייניקער ווי 1 מינוט פֿאַרבליבן" + } + }, + "yo": { + "stringUnit": { + "state": "needs_review", + "value": "Ó kù kéré ju ìṣẹ́jú kan lọ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "剩余不足 1 分钟" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "needs_review", + "value": "剩餘不到 1 分鐘" + } + }, + "zu": { + "stringUnit": { + "state": "needs_review", + "value": "Kusele ngaphansi komzuzu owodwa" + } + } + }, + "comment": "Battery power source or runtime. Preserve %@ when present. Added translations require native-speaker review." + }, + "%@ until full": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ until full" + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "%@ tills fulladdat" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ bis vollständig geladen" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@ avant la charge complète" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%@ hasta la carga completa" + } + }, + "af": { + "stringUnit": { + "state": "needs_review", + "value": "%@ tot vol" + } + }, + "am": { + "stringUnit": { + "state": "needs_review", + "value": "ሙሉ እስኪሞላ %@" + } + }, + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "%@ حتى اكتمال الشحن" + } + }, + "as": { + "stringUnit": { + "state": "needs_review", + "value": "সম্পূৰ্ণ হ’বলৈ %@" + } + }, + "ay": { + "stringUnit": { + "state": "needs_review", + "value": "%@ phuqañapataki" + } + }, + "az": { + "stringUnit": { + "state": "needs_review", + "value": "Tam dolana qədər %@" + } + }, + "be": { + "stringUnit": { + "state": "needs_review", + "value": "Да поўнай зарадкі %@" + } + }, + "bg": { + "stringUnit": { + "state": "needs_review", + "value": "%@ до пълно зареждане" + } + }, + "bm": { + "stringUnit": { + "state": "needs_review", + "value": "%@ ka batiri fa" + } + }, + "bn": { + "stringUnit": { + "state": "needs_review", + "value": "সম্পূর্ণ চার্জ হতে %@" + } + }, + "bo": { + "stringUnit": { + "state": "needs_review", + "value": "ཆ་ཚང་གསོག་པར་%@" + } + }, + "bs": { + "stringUnit": { + "state": "needs_review", + "value": "%@ do punog kapaciteta" + } + }, + "ca": { + "stringUnit": { + "state": "needs_review", + "value": "%@ fins a la càrrega completa" + } + }, + "ceb": { + "stringUnit": { + "state": "needs_review", + "value": "%@ hangtod mapuno" + } + }, + "co": { + "stringUnit": { + "state": "needs_review", + "value": "%@ finu à a carica cumpleta" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "%@ do plného nabití" + } + }, + "cy": { + "stringUnit": { + "state": "needs_review", + "value": "%@ nes ei fod yn llawn" + } + }, + "da": { + "stringUnit": { + "state": "needs_review", + "value": "%@ til fuldt opladet" + } + }, + "dv": { + "stringUnit": { + "state": "needs_review", + "value": "ފުރުމަށް %@" + } + }, + "el": { + "stringUnit": { + "state": "needs_review", + "value": "%@ μέχρι την πλήρη φόρτιση" + } + }, + "eo": { + "stringUnit": { + "state": "needs_review", + "value": "%@ ĝis plena ŝargo" + } + }, + "et": { + "stringUnit": { + "state": "needs_review", + "value": "Täislaadimiseni %@" + } + }, + "eu": { + "stringUnit": { + "state": "needs_review", + "value": "%@ guztiz kargatzeko" + } + }, + "fa": { + "stringUnit": { + "state": "needs_review", + "value": "%@ تا شارژ کامل" + } + }, + "fi": { + "stringUnit": { + "state": "needs_review", + "value": "%@ täyteen lataukseen" + } + }, + "fil": { + "stringUnit": { + "state": "needs_review", + "value": "%@ hanggang mapuno" + } + }, + "fy": { + "stringUnit": { + "state": "needs_review", + "value": "%@ oant fol" + } + }, + "ga": { + "stringUnit": { + "state": "needs_review", + "value": "%@ go lánluchtú" + } + }, + "gd": { + "stringUnit": { + "state": "needs_review", + "value": "%@ gus am bi e làn" + } + }, + "gl": { + "stringUnit": { + "state": "needs_review", + "value": "%@ ata a carga completa" + } + }, + "gn": { + "stringUnit": { + "state": "needs_review", + "value": "%@ henyhẽmba peve" + } + }, + "gu": { + "stringUnit": { + "state": "needs_review", + "value": "પૂરેપૂરી ચાર્જ થવામાં %@" + } + }, + "ha": { + "stringUnit": { + "state": "needs_review", + "value": "%@ kafin ya cika" + } + }, + "haw": { + "stringUnit": { + "state": "needs_review", + "value": "%@ a piha" + } + }, + "he": { + "stringUnit": { + "state": "needs_review", + "value": "%@ עד לטעינה מלאה" + } + }, + "hi": { + "stringUnit": { + "state": "needs_review", + "value": "पूरा चार्ज होने तक %@" + } + }, + "hmn": { + "stringUnit": { + "state": "needs_review", + "value": "%@ kom puv" + } + }, + "hr": { + "stringUnit": { + "state": "needs_review", + "value": "%@ do potpune napunjenosti" + } + }, + "ht": { + "stringUnit": { + "state": "needs_review", + "value": "%@ anvan li plen" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "%@ a teljes feltöltésig" + } + }, + "hy": { + "stringUnit": { + "state": "needs_review", + "value": "%@ մինչև լրիվ լիցքավորումը" + } + }, + "id": { + "stringUnit": { + "state": "needs_review", + "value": "%@ hingga penuh" + } + }, + "ig": { + "stringUnit": { + "state": "needs_review", + "value": "%@ tupu o ju eju" + } + }, + "is": { + "stringUnit": { + "state": "needs_review", + "value": "%@ þar til fullhlaðið" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "%@ alla carica completa" + } + }, + "ja": { + "stringUnit": { + "state": "needs_review", + "value": "フル充電まで%@" + } + }, + "jv": { + "stringUnit": { + "state": "needs_review", + "value": "%@ nganti kebak" + } + }, + "ka": { + "stringUnit": { + "state": "needs_review", + "value": "სრულ დატენვამდე %@" + } + }, + "kk": { + "stringUnit": { + "state": "needs_review", + "value": "Толық зарядталуға дейін %@" + } + }, + "km": { + "stringUnit": { + "state": "needs_review", + "value": "%@ រហូតដល់សាកពេញ" + } + }, + "kn": { + "stringUnit": { + "state": "needs_review", + "value": "ಪೂರ್ಣ ಚಾರ್ಜ್ ಆಗಲು %@" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "완충까지 %@" + } + }, + "ku": { + "stringUnit": { + "state": "needs_review", + "value": "%@ heta tijî bibe" + } + }, + "ky": { + "stringUnit": { + "state": "needs_review", + "value": "Толук кубатталганга чейин %@" + } + }, + "la": { + "stringUnit": { + "state": "needs_review", + "value": "%@ ad plenam capacitatem" + } + }, + "lb": { + "stringUnit": { + "state": "needs_review", + "value": "%@ bis voll opgelueden" + } + }, + "lo": { + "stringUnit": { + "state": "needs_review", + "value": "%@ ຈົນກວ່າຈະເຕັມ" + } + }, + "lt": { + "stringUnit": { + "state": "needs_review", + "value": "%@ iki visiško įkrovimo" + } + }, + "lv": { + "stringUnit": { + "state": "needs_review", + "value": "%@ līdz pilnai uzlādei" + } + }, + "mg": { + "stringUnit": { + "state": "needs_review", + "value": "%@ mandra-pahafeno" + } + }, + "mi": { + "stringUnit": { + "state": "needs_review", + "value": "%@ kia kī katoa" + } + }, + "mk": { + "stringUnit": { + "state": "needs_review", + "value": "%@ до целосно полнење" + } + }, + "ml": { + "stringUnit": { + "state": "needs_review", + "value": "പൂർണ്ണ ചാർജാകാൻ %@" + } + }, + "mn": { + "stringUnit": { + "state": "needs_review", + "value": "Бүрэн цэнэглэгдэхэд %@" + } + }, + "mr": { + "stringUnit": { + "state": "needs_review", + "value": "पूर्ण चार्ज होण्यासाठी %@" + } + }, + "ms": { + "stringUnit": { + "state": "needs_review", + "value": "%@ sehingga penuh" + } + }, + "mt": { + "stringUnit": { + "state": "needs_review", + "value": "%@ sakemm timtela" + } + }, + "my": { + "stringUnit": { + "state": "needs_review", + "value": "အားပြည့်ရန် %@" + } + }, + "nb": { + "stringUnit": { + "state": "needs_review", + "value": "%@ til fulladet" + } + }, + "ne": { + "stringUnit": { + "state": "needs_review", + "value": "पूरा चार्ज हुन %@" + } + }, + "nl": { + "stringUnit": { + "state": "needs_review", + "value": "%@ tot volledig opgeladen" + } + }, + "ny": { + "stringUnit": { + "state": "needs_review", + "value": "%@ kuti idzaze" + } + }, + "or": { + "stringUnit": { + "state": "needs_review", + "value": "ସମ୍ପୂର୍ଣ୍ଣ ଚାର୍ଜ ହେବାକୁ %@" + } + }, + "pa": { + "stringUnit": { + "state": "needs_review", + "value": "ਪੂਰਾ ਚਾਰਜ ਹੋਣ ਤੱਕ %@" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "%@ do pełnego naładowania" + } + }, + "ps": { + "stringUnit": { + "state": "needs_review", + "value": "تر بشپړ چارج پورې %@" + } + }, + "pt": { + "stringUnit": { + "state": "needs_review", + "value": "%@ até à carga completa" + } + }, + "ro": { + "stringUnit": { + "state": "needs_review", + "value": "%@ până la încărcarea completă" + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "%@ до полной зарядки" + } + }, + "rw": { + "stringUnit": { + "state": "needs_review", + "value": "%@ kugeza yuzuye" + } + }, + "sd": { + "stringUnit": { + "state": "needs_review", + "value": "مڪمل چارج ٿيڻ تائين %@" + } + }, + "si": { + "stringUnit": { + "state": "needs_review", + "value": "සම්පූර්ණ ආරෝපණයට %@" + } + }, + "sk": { + "stringUnit": { + "state": "needs_review", + "value": "%@ do úplného nabitia" + } + }, + "sl": { + "stringUnit": { + "state": "needs_review", + "value": "%@ do polne napolnjenosti" + } + }, + "sm": { + "stringUnit": { + "state": "needs_review", + "value": "%@ seʻia tumu" + } + }, + "sn": { + "stringUnit": { + "state": "needs_review", + "value": "%@ kuti rizare" + } + }, + "so": { + "stringUnit": { + "state": "needs_review", + "value": "%@ ilaa uu buuxsamo" + } + }, + "sq": { + "stringUnit": { + "state": "needs_review", + "value": "%@ deri në karikimin e plotë" + } + }, + "sr": { + "stringUnit": { + "state": "needs_review", + "value": "%@ до потпуног пуњења" + } + }, + "st": { + "stringUnit": { + "state": "needs_review", + "value": "%@ ho fihlela e tlala" + } + }, + "su": { + "stringUnit": { + "state": "needs_review", + "value": "%@ nepi ka pinuh" + } + }, + "sw": { + "stringUnit": { + "state": "needs_review", + "value": "%@ hadi ijae" + } + }, + "ta": { + "stringUnit": { + "state": "needs_review", + "value": "முழுமையாக மின்னேற %@" + } + }, + "te": { + "stringUnit": { + "state": "needs_review", + "value": "పూర్తిగా ఛార్జ్ కావడానికి %@" + } + }, + "tg": { + "stringUnit": { + "state": "needs_review", + "value": "%@ то пурра пур шудан" + } + }, + "th": { + "stringUnit": { + "state": "needs_review", + "value": "อีก %@ จนกว่าจะชาร์จเต็ม" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Tam doluma %@" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "%@ до повного заряджання" + } + }, + "ur": { + "stringUnit": { + "state": "needs_review", + "value": "مکمل چارج ہونے تک %@" + } + }, + "uz": { + "stringUnit": { + "state": "needs_review", + "value": "Toʻliq quvvatlanishgacha %@" + } + }, + "vi": { + "stringUnit": { + "state": "needs_review", + "value": "%@ đến khi sạc đầy" + } + }, + "xh": { + "stringUnit": { + "state": "needs_review", + "value": "%@ de igcwale" + } + }, + "yi": { + "stringUnit": { + "state": "needs_review", + "value": "%@ ביז פֿול" + } + }, + "yo": { + "stringUnit": { + "state": "needs_review", + "value": "%@ kí ó tó kún" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "距离充满还有 %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "needs_review", + "value": "距離充滿還有 %@" + } + }, + "zu": { + "stringUnit": { + "state": "needs_review", + "value": "%@ kuze kugcwale" + } + } + }, + "comment": "Battery power source or runtime. Preserve %@ when present. Added translations require native-speaker review." + }, + "%@ remaining": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ remaining" + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "%@ kvar" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ verbleibend" + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Temps restant : %@" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Tiempo restante: %@" + } + }, + "af": { + "stringUnit": { + "state": "needs_review", + "value": "%@ oor" + } + }, + "am": { + "stringUnit": { + "state": "needs_review", + "value": "%@ ቀርቷል" + } + }, + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "المتبقي: %@" + } + }, + "as": { + "stringUnit": { + "state": "needs_review", + "value": "%@ বাকী" + } + }, + "ay": { + "stringUnit": { + "state": "needs_review", + "value": "%@ qhiparaski" + } + }, + "az": { + "stringUnit": { + "state": "needs_review", + "value": "%@ qalıb" + } + }, + "be": { + "stringUnit": { + "state": "needs_review", + "value": "Засталося %@" + } + }, + "bg": { + "stringUnit": { + "state": "needs_review", + "value": "Остава %@" + } + }, + "bm": { + "stringUnit": { + "state": "needs_review", + "value": "%@ tora" + } + }, + "bn": { + "stringUnit": { + "state": "needs_review", + "value": "%@ বাকি" + } + }, + "bo": { + "stringUnit": { + "state": "needs_review", + "value": "%@ ལྷག་ཡོད།" + } + }, + "bs": { + "stringUnit": { + "state": "needs_review", + "value": "Preostalo %@" + } + }, + "ca": { + "stringUnit": { + "state": "needs_review", + "value": "Temps restant: %@" + } + }, + "ceb": { + "stringUnit": { + "state": "needs_review", + "value": "%@ ang nahibilin" + } + }, + "co": { + "stringUnit": { + "state": "needs_review", + "value": "Fermanu %@" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Zbývá %@" + } + }, + "cy": { + "stringUnit": { + "state": "needs_review", + "value": "%@ yn weddill" + } + }, + "da": { + "stringUnit": { + "state": "needs_review", + "value": "%@ tilbage" + } + }, + "dv": { + "stringUnit": { + "state": "needs_review", + "value": "%@ ބާކީ" + } + }, + "el": { + "stringUnit": { + "state": "needs_review", + "value": "Υπόλοιπος χρόνος: %@" + } + }, + "eo": { + "stringUnit": { + "state": "needs_review", + "value": "Restas %@" + } + }, + "et": { + "stringUnit": { + "state": "needs_review", + "value": "Jäänud %@" + } + }, + "eu": { + "stringUnit": { + "state": "needs_review", + "value": "%@ falta da" + } + }, + "fa": { + "stringUnit": { + "state": "needs_review", + "value": "%@ باقی مانده" + } + }, + "fi": { + "stringUnit": { + "state": "needs_review", + "value": "%@ jäljellä" + } + }, + "fil": { + "stringUnit": { + "state": "needs_review", + "value": "%@ ang natitira" + } + }, + "fy": { + "stringUnit": { + "state": "needs_review", + "value": "%@ oer" + } + }, + "ga": { + "stringUnit": { + "state": "needs_review", + "value": "%@ fágtha" + } + }, + "gd": { + "stringUnit": { + "state": "needs_review", + "value": "%@ air fhàgail" + } + }, + "gl": { + "stringUnit": { + "state": "needs_review", + "value": "Tempo restante: %@" + } + }, + "gn": { + "stringUnit": { + "state": "needs_review", + "value": "%@ hembýva" + } + }, + "gu": { + "stringUnit": { + "state": "needs_review", + "value": "%@ બાકી" + } + }, + "ha": { + "stringUnit": { + "state": "needs_review", + "value": "%@ ya rage" + } + }, + "haw": { + "stringUnit": { + "state": "needs_review", + "value": "%@ i koe" + } + }, + "he": { + "stringUnit": { + "state": "needs_review", + "value": "יתרת זמן: %@" + } + }, + "hi": { + "stringUnit": { + "state": "needs_review", + "value": "%@ शेष" + } + }, + "hmn": { + "stringUnit": { + "state": "needs_review", + "value": "Tshuav %@" + } + }, + "hr": { + "stringUnit": { + "state": "needs_review", + "value": "Preostalo %@" + } + }, + "ht": { + "stringUnit": { + "state": "needs_review", + "value": "%@ rete" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "%@ van hátra" + } + }, + "hy": { + "stringUnit": { + "state": "needs_review", + "value": "Մնացել է %@" + } + }, + "id": { + "stringUnit": { + "state": "needs_review", + "value": "%@ tersisa" + } + }, + "ig": { + "stringUnit": { + "state": "needs_review", + "value": "%@ fọdụrụ" + } + }, + "is": { + "stringUnit": { + "state": "needs_review", + "value": "%@ eftir" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Tempo rimanente: %@" + } + }, + "ja": { + "stringUnit": { + "state": "needs_review", + "value": "残り%@" + } + }, + "jv": { + "stringUnit": { + "state": "needs_review", + "value": "Isih %@" + } + }, + "ka": { + "stringUnit": { + "state": "needs_review", + "value": "დარჩენილია %@" + } + }, + "kk": { + "stringUnit": { + "state": "needs_review", + "value": "%@ қалды" + } + }, + "km": { + "stringUnit": { + "state": "needs_review", + "value": "នៅសល់ %@" + } + }, + "kn": { + "stringUnit": { + "state": "needs_review", + "value": "%@ ಬಾಕಿ" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "%@ 남음" + } + }, + "ku": { + "stringUnit": { + "state": "needs_review", + "value": "%@ maye" + } + }, + "ky": { + "stringUnit": { + "state": "needs_review", + "value": "%@ калды" + } + }, + "la": { + "stringUnit": { + "state": "needs_review", + "value": "%@ restat" + } + }, + "lb": { + "stringUnit": { + "state": "needs_review", + "value": "%@ iwwreg" + } + }, + "lo": { + "stringUnit": { + "state": "needs_review", + "value": "ເຫຼືອ %@" + } + }, + "lt": { + "stringUnit": { + "state": "needs_review", + "value": "Liko %@" + } + }, + "lv": { + "stringUnit": { + "state": "needs_review", + "value": "Atlikušais laiks: %@" + } + }, + "mg": { + "stringUnit": { + "state": "needs_review", + "value": "%@ sisa" + } + }, + "mi": { + "stringUnit": { + "state": "needs_review", + "value": "%@ e toe ana" + } + }, + "mk": { + "stringUnit": { + "state": "needs_review", + "value": "Преостанува %@" + } + }, + "ml": { + "stringUnit": { + "state": "needs_review", + "value": "%@ ബാക്കി" + } + }, + "mn": { + "stringUnit": { + "state": "needs_review", + "value": "%@ үлдсэн" + } + }, + "mr": { + "stringUnit": { + "state": "needs_review", + "value": "%@ शिल्लक" + } + }, + "ms": { + "stringUnit": { + "state": "needs_review", + "value": "%@ lagi" + } + }, + "mt": { + "stringUnit": { + "state": "needs_review", + "value": "Fadal %@" + } + }, + "my": { + "stringUnit": { + "state": "needs_review", + "value": "%@ ကျန်ရှိသည်" + } + }, + "nb": { + "stringUnit": { + "state": "needs_review", + "value": "%@ igjen" + } + }, + "ne": { + "stringUnit": { + "state": "needs_review", + "value": "%@ बाँकी" + } + }, + "nl": { + "stringUnit": { + "state": "needs_review", + "value": "%@ resterend" + } + }, + "ny": { + "stringUnit": { + "state": "needs_review", + "value": "Kwatsala %@" + } + }, + "or": { + "stringUnit": { + "state": "needs_review", + "value": "%@ ବାକି" + } + }, + "pa": { + "stringUnit": { + "state": "needs_review", + "value": "%@ ਬਾਕੀ" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Pozostały czas: %@" + } + }, + "ps": { + "stringUnit": { + "state": "needs_review", + "value": "%@ پاتې دی" + } + }, + "pt": { + "stringUnit": { + "state": "needs_review", + "value": "Tempo restante: %@" + } + }, + "ro": { + "stringUnit": { + "state": "needs_review", + "value": "Timp rămas: %@" + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Осталось %@" + } + }, + "rw": { + "stringUnit": { + "state": "needs_review", + "value": "Hasigaye %@" + } + }, + "sd": { + "stringUnit": { + "state": "needs_review", + "value": "%@ باقي" + } + }, + "si": { + "stringUnit": { + "state": "needs_review", + "value": "%@ ඉතිරිව ඇත" + } + }, + "sk": { + "stringUnit": { + "state": "needs_review", + "value": "Zostáva %@" + } + }, + "sl": { + "stringUnit": { + "state": "needs_review", + "value": "Preostalo %@" + } + }, + "sm": { + "stringUnit": { + "state": "needs_review", + "value": "%@ o totoe" + } + }, + "sn": { + "stringUnit": { + "state": "needs_review", + "value": "Pasara %@" + } + }, + "so": { + "stringUnit": { + "state": "needs_review", + "value": "%@ ayaa hadhay" + } + }, + "sq": { + "stringUnit": { + "state": "needs_review", + "value": "Koha e mbetur: %@" + } + }, + "sr": { + "stringUnit": { + "state": "needs_review", + "value": "Преостало %@" + } + }, + "st": { + "stringUnit": { + "state": "needs_review", + "value": "Ho setse %@" + } + }, + "su": { + "stringUnit": { + "state": "needs_review", + "value": "Sésana %@" + } + }, + "sw": { + "stringUnit": { + "state": "needs_review", + "value": "%@ imesalia" + } + }, + "ta": { + "stringUnit": { + "state": "needs_review", + "value": "%@ மீதமுள்ளது" + } + }, + "te": { + "stringUnit": { + "state": "needs_review", + "value": "%@ మిగిలి ఉంది" + } + }, + "tg": { + "stringUnit": { + "state": "needs_review", + "value": "%@ мондааст" + } + }, + "th": { + "stringUnit": { + "state": "needs_review", + "value": "เหลือ %@" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "%@ kaldı" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Залишилося %@" + } + }, + "ur": { + "stringUnit": { + "state": "needs_review", + "value": "%@ باقی" + } + }, + "uz": { + "stringUnit": { + "state": "needs_review", + "value": "%@ qoldi" + } + }, + "vi": { + "stringUnit": { + "state": "needs_review", + "value": "Còn %@" + } + }, + "xh": { + "stringUnit": { + "state": "needs_review", + "value": "Kushiyeke %@" + } + }, + "yi": { + "stringUnit": { + "state": "needs_review", + "value": "%@ פֿאַרבליבן" + } + }, + "yo": { + "stringUnit": { + "state": "needs_review", + "value": "%@ ló kù" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "剩余 %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "needs_review", + "value": "剩餘 %@" + } + }, + "zu": { + "stringUnit": { + "state": "needs_review", + "value": "Kusele %@" + } + } + }, + "comment": "Battery power source or runtime. Preserve %@ when present. Added translations require native-speaker review." + }, + "Charging": { + "extractionState": "manual", + "comment": "Battery power state. New or corrected translations require native-speaker review.", + "localizations": { + "af": { + "stringUnit": { + "state": "translated", + "value": "Laai" + } + }, + "am": { + "stringUnit": { + "state": "translated", + "value": "በመሙላት ላይ" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "الشحن" + } + }, + "as": { + "stringUnit": { + "state": "needs_review", + "value": "চাৰ্জ হৈ আছে" + } + }, + "ay": { + "stringUnit": { + "state": "needs_review", + "value": "Ch’ama phuqachaski" + } + }, + "az": { + "stringUnit": { + "state": "translated", + "value": "Doldurulur" + } + }, + "be": { + "stringUnit": { + "state": "translated", + "value": "Зарадка" + } + }, + "bg": { + "stringUnit": { + "state": "translated", + "value": "Зареждане" + } + }, + "bm": { + "stringUnit": { + "state": "needs_review", + "value": "Batiri bɛ fa" + } + }, + "bn": { + "stringUnit": { + "state": "translated", + "value": "চার্জিং" + } + }, + "bo": { + "stringUnit": { + "state": "needs_review", + "value": "གློག་གསོག་བཞིན་པ།" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Punjenje" + } + }, + "ca": { + "stringUnit": { + "state": "translated", + "value": "Carregant" + } + }, + "ceb": { + "stringUnit": { + "state": "translated", + "value": "Pag-charge" + } + }, + "co": { + "stringUnit": { + "state": "translated", + "value": "Carica" + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Nabíjení" + } + }, + "cy": { + "stringUnit": { + "state": "translated", + "value": "Codi tâl" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Opladning" + } + }, + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Wird geladen" + } + }, + "dv": { + "stringUnit": { + "state": "needs_review", + "value": "ޗާޖުވަމުންދަނީ" + } + }, + "el": { + "stringUnit": { + "state": "translated", + "value": "Φόρτιση" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Charging" + } + }, + "eo": { + "stringUnit": { + "state": "translated", + "value": "Ŝargado" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cargando" + } + }, + "et": { + "stringUnit": { + "state": "translated", + "value": "Laadimine" + } + }, + "eu": { + "stringUnit": { + "state": "translated", + "value": "Kargatzen" + } + }, + "fa": { + "stringUnit": { + "state": "translated", + "value": "شارژ کردن" + } + }, + "fi": { + "stringUnit": { + "state": "translated", + "value": "Lataus" + } + }, + "fil": { + "stringUnit": { + "state": "translated", + "value": "Nagcha-charge" + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "En charge" + } + }, + "fy": { + "stringUnit": { + "state": "translated", + "value": "Opladen" + } + }, + "ga": { + "stringUnit": { + "state": "translated", + "value": "Muirearú" + } + }, + "gd": { + "stringUnit": { + "state": "translated", + "value": "A 'cur cosgais" + } + }, + "gl": { + "stringUnit": { + "state": "translated", + "value": "Cargando" + } + }, + "gn": { + "stringUnit": { + "state": "needs_review", + "value": "Omohenyhẽhína" + } + }, + "gu": { + "stringUnit": { + "state": "translated", + "value": "ચાર્જિંગ" + } + }, + "ha": { + "stringUnit": { + "state": "translated", + "value": "Cajin" + } + }, + "haw": { + "stringUnit": { + "state": "translated", + "value": "Hoʻopiʻi" + } + }, + "he": { + "stringUnit": { + "state": "translated", + "value": "טעינה" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "चार्जिंग" + } + }, + "hmn": { + "stringUnit": { + "state": "translated", + "value": "Kev them nyiaj" + } + }, + "hr": { + "stringUnit": { + "state": "translated", + "value": "Punjenje" + } + }, + "ht": { + "stringUnit": { + "state": "translated", + "value": "Chaje" + } + }, + "hu": { + "stringUnit": { + "state": "translated", + "value": "Töltés" + } + }, + "hy": { + "stringUnit": { + "state": "translated", + "value": "Լիցքավորում" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Mengisi daya" + } + }, + "ig": { + "stringUnit": { + "state": "translated", + "value": "Nchaji" + } + }, + "is": { + "stringUnit": { + "state": "translated", + "value": "Hleðsla" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "In carica" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "充電中" + } + }, + "jv": { + "stringUnit": { + "state": "translated", + "value": "Ngisi daya" + } + }, + "ka": { + "stringUnit": { + "state": "translated", + "value": "დამუხტვა" + } + }, + "kk": { + "stringUnit": { + "state": "translated", + "value": "Зарядталуда" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "សាកថ្ម" + } + }, + "kn": { + "stringUnit": { + "state": "translated", + "value": "ಚಾರ್ಜ್ ಆಗುತ್ತಿದೆ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "충전 중" + } + }, + "ku": { + "stringUnit": { + "state": "needs_review", + "value": "Şarj dibe" + } + }, + "ky": { + "stringUnit": { + "state": "translated", + "value": "Заряддоо" + } + }, + "la": { + "stringUnit": { + "state": "translated", + "value": "praecipiens" + } + }, + "lb": { + "stringUnit": { + "state": "translated", + "value": "Opluedstatiounen" + } + }, + "lo": { + "stringUnit": { + "state": "translated", + "value": "ກຳລັງສາກໄຟ" + } + }, + "lt": { + "stringUnit": { + "state": "translated", + "value": "Įkrovimas" + } + }, + "lv": { + "stringUnit": { + "state": "translated", + "value": "Uzlāde" + } + }, + "mg": { + "stringUnit": { + "state": "needs_review", + "value": "Eo am-pamahanana" + } + }, + "mi": { + "stringUnit": { + "state": "translated", + "value": "Te utu" + } + }, + "mk": { + "stringUnit": { + "state": "translated", + "value": "Полнење" + } + }, + "ml": { + "stringUnit": { + "state": "translated", + "value": "ചാർജിംഗ്" + } + }, + "mn": { + "stringUnit": { + "state": "translated", + "value": "Цэнэглэж байна" + } + }, + "mr": { + "stringUnit": { + "state": "translated", + "value": "चार्ज होत आहे" + } + }, + "ms": { + "stringUnit": { + "state": "translated", + "value": "Mengecas" + } + }, + "mt": { + "stringUnit": { + "state": "translated", + "value": "Iċċarġjar" + } + }, + "my": { + "stringUnit": { + "state": "translated", + "value": "အားသွင်းခြင်း။" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Lader" + } + }, + "ne": { + "stringUnit": { + "state": "translated", + "value": "चार्ज गर्दै" + } + }, + "nl": { + "stringUnit": { + "state": "translated", + "value": "Opladen" + } + }, + "ny": { + "stringUnit": { + "state": "translated", + "value": "Kulipira" + } + }, + "or": { + "stringUnit": { + "state": "needs_review", + "value": "ଚାର୍ଜ ହେଉଛି" + } + }, + "pa": { + "stringUnit": { + "state": "translated", + "value": "ਚਾਰਜ ਹੋ ਰਿਹਾ ਹੈ" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Ładowanie" + } + }, + "ps": { + "stringUnit": { + "state": "translated", + "value": "چارج کول" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Carregando" + } + }, + "ro": { + "stringUnit": { + "state": "translated", + "value": "Încărcare" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Зарядка" + } + }, + "rw": { + "stringUnit": { + "state": "needs_review", + "value": "Bateri irimo kuzura" + } + }, + "sd": { + "stringUnit": { + "state": "translated", + "value": "چارج ڪرڻ" + } + }, + "si": { + "stringUnit": { + "state": "translated", + "value": "අයකිරීම" + } + }, + "sk": { + "stringUnit": { + "state": "translated", + "value": "Nabíjanie" + } + }, + "sl": { + "stringUnit": { + "state": "translated", + "value": "Polnjenje" + } + }, + "sm": { + "stringUnit": { + "state": "translated", + "value": "Totogiina" + } + }, + "sn": { + "stringUnit": { + "state": "translated", + "value": "Kuchaja" + } + }, + "so": { + "stringUnit": { + "state": "translated", + "value": "Dallacaadda" + } + }, + "sq": { + "stringUnit": { + "state": "translated", + "value": "Ngarkimi" + } + }, + "sr": { + "stringUnit": { + "state": "needs_review", + "value": "Пуни се" + } + }, + "st": { + "stringUnit": { + "state": "translated", + "value": "Ho tjhaja" + } + }, + "su": { + "stringUnit": { + "state": "translated", + "value": "Ngecas" + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Laddar" + } + }, + "sw": { + "stringUnit": { + "state": "translated", + "value": "Inachaji" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "சார்ஜ் செய்கிறது" + } + }, + "te": { + "stringUnit": { + "state": "translated", + "value": "ఛార్జింగ్" + } + }, + "tg": { + "stringUnit": { + "state": "translated", + "value": "Пур кардан" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "กำลังชาร์จ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Şarj etme" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Зарядка" + } + }, + "ur": { + "stringUnit": { + "state": "translated", + "value": "چارج ہو رہا ہے۔" + } + }, + "uz": { + "stringUnit": { + "state": "translated", + "value": "Zaryadlanmoqda" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Sạc" + } + }, + "xh": { + "stringUnit": { + "state": "translated", + "value": "Ukutshaja" + } + }, + "yi": { + "stringUnit": { + "state": "translated", + "value": "טשאַרדזשינג" + } + }, + "yo": { + "stringUnit": { + "state": "translated", + "value": "Gbigba agbara" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "充电" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "充電" + } + }, + "zu": { + "stringUnit": { + "state": "translated", + "value": "Iyashaja" + } } } }, - "Power Adapter": { + "AC Power": { "extractionState": "manual", + "comment": "Battery power state. New or corrected translations require native-speaker review.", "localizations": { + "af": { + "stringUnit": { + "state": "translated", + "value": "AC krag" + } + }, + "am": { + "stringUnit": { + "state": "translated", + "value": "የ AC ኃይል" + } + }, + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "طاقة خارجية" + } + }, + "as": { + "stringUnit": { + "state": "needs_review", + "value": "পাৱাৰ এডাপ্টাৰ" + } + }, + "ay": { + "stringUnit": { + "state": "needs_review", + "value": "Ch’ama adaptador" + } + }, + "az": { + "stringUnit": { + "state": "translated", + "value": "AC Gücü" + } + }, + "be": { + "stringUnit": { + "state": "translated", + "value": "Пераменнае сілкаванне" + } + }, + "bg": { + "stringUnit": { + "state": "translated", + "value": "AC захранване" + } + }, + "bm": { + "stringUnit": { + "state": "needs_review", + "value": "Kuran adaptatɛri" + } + }, + "bn": { + "stringUnit": { + "state": "translated", + "value": "এসি পাওয়ার" + } + }, + "bo": { + "stringUnit": { + "state": "needs_review", + "value": "གློག་ཁུངས་མཐུན་སྒྲིག་ཆས།" + } + }, + "bs": { + "stringUnit": { + "state": "needs_review", + "value": "Adapter za napajanje" + } + }, + "ca": { + "stringUnit": { + "state": "translated", + "value": "Alimentació CA" + } + }, + "ceb": { + "stringUnit": { + "state": "translated", + "value": "Gahum sa AC" + } + }, + "co": { + "stringUnit": { + "state": "needs_review", + "value": "Adattatore d’alimentazione" + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Napájení střídavým proudem" + } + }, + "cy": { + "stringUnit": { + "state": "translated", + "value": "Pŵer AC" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "AC strøm" + } + }, + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Netzbetrieb" + } + }, + "dv": { + "stringUnit": { + "state": "needs_review", + "value": "ޕަވަރ އެޑެޕްޓަރ" + } + }, + "el": { + "stringUnit": { + "state": "translated", + "value": "Εναλλασσόμενο ρεύμα" + } + }, "en": { "stringUnit": { "state": "translated", - "value": "Power Adapter" + "value": "AC Power" + } + }, + "eo": { + "stringUnit": { + "state": "translated", + "value": "AC Potenco" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Corriente alterna" + } + }, + "et": { + "stringUnit": { + "state": "translated", + "value": "Vahelduvvool" + } + }, + "eu": { + "stringUnit": { + "state": "translated", + "value": "AC potentzia" + } + }, + "fa": { + "stringUnit": { + "state": "translated", + "value": "برق AC" + } + }, + "fi": { + "stringUnit": { + "state": "translated", + "value": "AC virta" + } + }, + "fil": { + "stringUnit": { + "state": "needs_review", + "value": "Adaptor ng kuryente" + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Alimentation secteur" + } + }, + "fy": { + "stringUnit": { + "state": "needs_review", + "value": "Stroomadapter" + } + }, + "ga": { + "stringUnit": { + "state": "translated", + "value": "Cumhacht AC" + } + }, + "gd": { + "stringUnit": { + "state": "translated", + "value": "Cumhachd AC" + } + }, + "gl": { + "stringUnit": { + "state": "translated", + "value": "Alimentación CA" + } + }, + "gn": { + "stringUnit": { + "state": "needs_review", + "value": "Tendyry adaptador" + } + }, + "gu": { + "stringUnit": { + "state": "translated", + "value": "એસી પાવર" + } + }, + "ha": { + "stringUnit": { + "state": "translated", + "value": "Wutar AC" + } + }, + "haw": { + "stringUnit": { + "state": "translated", + "value": "Mana AC" } }, - "sv": { + "he": { "stringUnit": { "state": "translated", - "value": "Nätadapter" + "value": "מתח AC" } }, - "de": { + "hi": { "stringUnit": { "state": "translated", - "value": "Netzteil" + "value": "एसी पावर" } }, - "fr": { + "hmn": { "stringUnit": { "state": "translated", - "value": "Adaptateur secteur" + "value": "AC zog" } }, - "es": { + "hr": { "stringUnit": { "state": "translated", - "value": "Adaptador de corriente" + "value": "AC napajanje" } - } - } - }, - "Internal Battery": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "ht": { "stringUnit": { "state": "translated", - "value": "Internal Battery" + "value": "AC pouvwa" } }, - "sv": { + "hu": { "stringUnit": { "state": "translated", - "value": "Internt batteri" + "value": "AC táp" } }, - "de": { + "hy": { + "stringUnit": { + "state": "needs_review", + "value": "Սնուցման ադապտեր" + } + }, + "id": { "stringUnit": { "state": "translated", - "value": "Interne Batterie" + "value": "Daya AC" } }, - "fr": { + "ig": { "stringUnit": { "state": "translated", - "value": "Batterie interne" + "value": "Ike AC" } }, - "es": { + "is": { + "stringUnit": { + "state": "needs_review", + "value": "Straumbreytir" + } + }, + "it": { "stringUnit": { "state": "translated", - "value": "Batería interna" + "value": "Alimentazione CA" } - } - } - }, - "Finishing soon": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "ja": { + "stringUnit": { + "state": "needs_review", + "value": "外部電源" + } + }, + "jv": { "stringUnit": { "state": "translated", - "value": "Finishing soon" + "value": "Daya AC" } }, - "sv": { + "ka": { "stringUnit": { "state": "translated", - "value": "Snart klart" + "value": "AC სიმძლავრე" } }, - "de": { + "kk": { "stringUnit": { "state": "translated", - "value": "Bald abgeschlossen" + "value": "Айнымалы ток қуаты" } }, - "fr": { + "km": { "stringUnit": { "state": "translated", - "value": "Bientôt terminé" + "value": "ថាមពល AC" } }, - "es": { + "kn": { "stringUnit": { "state": "translated", - "value": "Finaliza pronto" + "value": "ಎಸಿ ಪವರ್" } - } - } - }, - "Less than 1m remaining": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "ko": { "stringUnit": { "state": "translated", - "value": "Less than 1m remaining" + "value": "AC 전원" } }, - "sv": { + "ku": { + "stringUnit": { + "state": "needs_review", + "value": "Adaptera elektrîkê" + } + }, + "ky": { + "stringUnit": { + "state": "needs_review", + "value": "Кубат адаптери" + } + }, + "la": { + "stringUnit": { + "state": "needs_review", + "value": "Adaptor electricus" + } + }, + "lb": { + "stringUnit": { + "state": "needs_review", + "value": "Stroumadapter" + } + }, + "lo": { "stringUnit": { "state": "translated", - "value": "Mindre än 1 min kvar" + "value": "ໄຟຟ້າ AC" } }, - "de": { + "lt": { "stringUnit": { "state": "translated", - "value": "Weniger als 1 Min. verbleibend" + "value": "AC maitinimas" } }, - "fr": { + "lv": { "stringUnit": { "state": "translated", - "value": "Moins de 1 min restante" + "value": "Maiņstrāva" } }, - "es": { + "mg": { + "stringUnit": { + "state": "needs_review", + "value": "Adaptatera herinaratra" + } + }, + "mi": { "stringUnit": { "state": "translated", - "value": "Menos de 1 min restante" + "value": "Mana AC" } - } - } - }, - "%@ until full": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "mk": { "stringUnit": { "state": "translated", - "value": "%@ until full" + "value": "AC напојување" } }, - "sv": { + "ml": { "stringUnit": { "state": "translated", - "value": "%@ tills fulladdat" + "value": "എസി പവർ" } }, - "de": { + "mn": { "stringUnit": { "state": "translated", - "value": "%@ bis vollständig geladen" + "value": "Хувьсах гүйдлийн хүч" } }, - "fr": { + "mr": { "stringUnit": { "state": "translated", - "value": "%@ avant la charge complète" + "value": "एसी पॉवर" } }, - "es": { + "ms": { "stringUnit": { "state": "translated", - "value": "%@ hasta la carga completa" + "value": "Kuasa AC" } - } - } - }, - "%@ remaining": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "mt": { "stringUnit": { "state": "translated", - "value": "%@ remaining" + "value": "Qawwa AC" + } + }, + "my": { + "stringUnit": { + "state": "needs_review", + "value": "ပါဝါအဒက်တာ" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Vekselstrøm" + } + }, + "ne": { + "stringUnit": { + "state": "translated", + "value": "एसी पावर" + } + }, + "nl": { + "stringUnit": { + "state": "translated", + "value": "Wisselstroom" + } + }, + "ny": { + "stringUnit": { + "state": "translated", + "value": "Mphamvu ya AC" + } + }, + "or": { + "stringUnit": { + "state": "needs_review", + "value": "ପାୱାର ଆଡାପ୍ଟର" + } + }, + "pa": { + "stringUnit": { + "state": "translated", + "value": "AC ਪਾਵਰ" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zasilanie sieciowe" + } + }, + "ps": { + "stringUnit": { + "state": "translated", + "value": "د AC بریښنا" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Alimentação CA" + } + }, + "ro": { + "stringUnit": { + "state": "translated", + "value": "Alimentare AC" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Питание переменного тока" + } + }, + "rw": { + "stringUnit": { + "state": "needs_review", + "value": "Adaputeri y’amashanyarazi" + } + }, + "sd": { + "stringUnit": { + "state": "translated", + "value": "اي سي پاور" + } + }, + "si": { + "stringUnit": { + "state": "translated", + "value": "AC බලය" + } + }, + "sk": { + "stringUnit": { + "state": "translated", + "value": "Napájanie striedavým prúdom" + } + }, + "sl": { + "stringUnit": { + "state": "translated", + "value": "AC napajanje" + } + }, + "sm": { + "stringUnit": { + "state": "translated", + "value": "Malosiaga AC" + } + }, + "sn": { + "stringUnit": { + "state": "translated", + "value": "AC Simba" + } + }, + "so": { + "stringUnit": { + "state": "translated", + "value": "Awooda AC" + } + }, + "sq": { + "stringUnit": { + "state": "translated", + "value": "Fuqia AC" + } + }, + "sr": { + "stringUnit": { + "state": "needs_review", + "value": "Адаптер за напајање" + } + }, + "st": { + "stringUnit": { + "state": "translated", + "value": "Matla a AC" + } + }, + "su": { + "stringUnit": { + "state": "translated", + "value": "Daya AC" } }, "sv": { + "stringUnit": { + "state": "needs_review", + "value": "Nätström" + } + }, + "sw": { "stringUnit": { "state": "translated", - "value": "%@ kvar" + "value": "Nguvu ya AC" } }, - "de": { + "ta": { "stringUnit": { "state": "translated", - "value": "%@ verbleibend" + "value": "ஏசி பவர்" } }, - "fr": { + "te": { "stringUnit": { "state": "translated", - "value": "%@ restantes" + "value": "AC పవర్" } }, - "es": { + "tg": { + "stringUnit": { + "state": "translated", + "value": "Қувваи AC" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไฟฟ้ากระแสสลับ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "AC Gücü" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Живлення змінного струму" + } + }, + "ur": { + "stringUnit": { + "state": "translated", + "value": "AC پاور" + } + }, + "uz": { + "stringUnit": { + "state": "translated", + "value": "AC quvvati" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Nguồn điện xoay chiều" + } + }, + "xh": { + "stringUnit": { + "state": "translated", + "value": "Amandla e-AC" + } + }, + "yi": { + "stringUnit": { + "state": "translated", + "value": "אַק מאַכט" + } + }, + "yo": { + "stringUnit": { + "state": "translated", + "value": "AC Agbara" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "外接电源" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "needs_review", + "value": "外接電源" + } + }, + "zu": { "stringUnit": { "state": "translated", - "value": "%@ restantes" + "value": "Amandla e-AC" } } } diff --git a/Core-Monitor/Core_MonitorApp.swift b/Core-Monitor/Core_MonitorApp.swift index 1140d2e1..eaa418bc 100644 --- a/Core-Monitor/Core_MonitorApp.swift +++ b/Core-Monitor/Core_MonitorApp.swift @@ -336,6 +336,7 @@ final class CoreMonitorApplicationDelegate: NSObject, NSApplicationDelegate { } func openDashboard() { + guard didBootstrapPrimaryInstance else { return } guard shouldBootstrapInteractiveApp else { return } Self.logger.notice("Open dashboard requested activationPolicy=\(String(describing: NSApp.activationPolicy()), privacy: .public)") setDashboardActivationPolicy() diff --git a/Core-Monitor/SingleInstanceLock.swift b/Core-Monitor/SingleInstanceLock.swift index 83d535cf..6ae599c8 100644 --- a/Core-Monitor/SingleInstanceLock.swift +++ b/Core-Monitor/SingleInstanceLock.swift @@ -13,9 +13,9 @@ final class SingleInstanceLock { deinit { if let descriptor { Darwin.close(descriptor) } } static func fileURL(bundleIdentifier: String) throws -> URL { - let caches = try FileManager.default.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + let support = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) let name = bundleIdentifier.replacingOccurrences(of: "/", with: "_") - return caches.appendingPathComponent(name, isDirectory: true).appendingPathComponent("single-instance.lock") + return support.appendingPathComponent(name, isDirectory: true).appendingPathComponent("single-instance.lock") } func acquire() throws -> Bool { diff --git a/Core-Monitor/WeatherLocationNameCache.swift b/Core-Monitor/WeatherLocationNameCache.swift index b73e2936..ba2b351d 100644 --- a/Core-Monitor/WeatherLocationNameCache.swift +++ b/Core-Monitor/WeatherLocationNameCache.swift @@ -41,7 +41,8 @@ actor WeatherLocationNameCache { } if let pending, pending.localeIdentifier == locale.identifier, location.distance(from: pending.location) <= distanceThreshold { - return (try? await pending.task.value) ?? "Weather" + let resolved = try? await pending.task.value + return resolved.flatMap { $0.isEmpty ? nil : $0 } ?? "Weather" } let id = UUID() diff --git a/Core-MonitorTests/BatteryDetailFormatterTests.swift b/Core-MonitorTests/BatteryDetailFormatterTests.swift index f5e5dfdd..26b05f9f 100644 --- a/Core-MonitorTests/BatteryDetailFormatterTests.swift +++ b/Core-MonitorTests/BatteryDetailFormatterTests.swift @@ -56,4 +56,30 @@ final class BatteryDetailFormatterTests: XCTestCase { info.timeRemainingMinutes = 0 XCTAssertEqual(BatteryDetailFormatter.runtimeDescription(for: info, locale: locale), "Snart klart") } + + func testBatteryCatalogCoversEveryAdvertisedLocaleAndPreservesPlaceholders() throws { + let keys = ["Charging", "AC Power", "Battery Power", "Power Adapter", "Internal Battery", + "Finishing soon", "Less than 1m remaining", "%@ until full", "%@ remaining"] + for locale in AppLocaleStore.supportedLocaleIdentifiers { + let directory = try XCTUnwrap(Bundle.main.path(forResource: locale, ofType: "lproj"), locale) + let url = URL(fileURLWithPath: directory).appendingPathComponent("BatteryDetails.strings") + let table = try XCTUnwrap(try PropertyListSerialization.propertyList(from: Data(contentsOf: url), format: nil) as? [String: String], locale) + for key in keys { + let value = try XCTUnwrap(table[key], "\(locale): \(key)") + XCTAssertFalse(value.isEmpty, "\(locale): \(key)") + XCTAssertEqual(value.components(separatedBy: "%@").count, + key.components(separatedBy: "%@").count, "\(locale): \(key)") + } + } + } + + func testChargingAndExternalPowerUseBatteryCatalog() { + var battery = BatteryInfo() + battery.isCharging = true + let locale = Locale(identifier: "sv_SE") + XCTAssertEqual(BatteryDetailFormatter.powerStateDescription(for: battery, locale: locale), "Laddar") + battery.isCharging = false + battery.isPluggedIn = true + XCTAssertEqual(BatteryDetailFormatter.powerStateDescription(for: battery, locale: locale), "Nätström") + } } diff --git a/Core-MonitorTests/FollowupRegressionTests.swift b/Core-MonitorTests/FollowupRegressionTests.swift index 475403e1..c0a0715f 100644 --- a/Core-MonitorTests/FollowupRegressionTests.swift +++ b/Core-MonitorTests/FollowupRegressionTests.swift @@ -14,16 +14,24 @@ final class FollowupRegressionTests: XCTestCase { privacy.processInsightsEnabled = true var store = AlertStore.default() store.desktopNotificationsEnabled = false - store.history = [AlertEvent(id: UUID(), kind: .cpuUsage, severity: .warning, + store.ruleConfigs = store.ruleConfigs.map { config in + var config = config + config.isEnabled = false + return config + } + let eventID = UUID() + store.history = [AlertEvent(id: eventID, kind: .cpuUsage, severity: .warning, title: "CPU", message: "Busy", context: "PrivateProcess", timestamp: Date(), isRecovery: false)] defaults.set(try JSONEncoder().encode(store), forKey: "coremonitor.alertStore.v1") let monitor = SystemMonitor(privacySettings: privacy) let manager = AlertManager(systemMonitor: monitor, fanController: FanController(systemMonitor: monitor), privacySettings: privacy, userDefaults: defaults) - XCTAssertEqual(manager.history.first?.context, "PrivateProcess") + XCTAssertEqual(manager.history.first(where: { $0.id == eventID })?.context, "PrivateProcess") let redacted = expectation(description: "History redacted on main thread") - let subscription = manager.$history.dropFirst().sink { events in - guard events.first?.context == nil else { return } + let subscription = manager.$history.dropFirst() + .filter { $0.contains { $0.id == eventID && $0.context == nil } } + .prefix(1) + .sink { _ in XCTAssertTrue(Thread.isMainThread) redacted.fulfill() } @@ -32,7 +40,8 @@ final class FollowupRegressionTests: XCTestCase { subscription.cancel() XCTAssertFalse(manager.processInsightsEnabled) let persisted = try JSONDecoder().decode(AlertStore.self, from: XCTUnwrap(defaults.data(forKey: "coremonitor.alertStore.v1"))) - XCTAssertNil(persisted.history.first?.context) + let savedEvent = try XCTUnwrap(persisted.history.first(where: { $0.id == eventID })) + XCTAssertNil(savedEvent.context) } func testReusedPIDStartsNewDiskBaselineEvenWhenCountersIncrease() { From dc26dfd44c898431d37236f08c1cf6ac7c1026d5 Mon Sep 17 00:00:00 2001 From: SmokeStudios <290871035+OGSmokeStudios@users.noreply.github.com> Date: Sat, 19 Sep 2026 12:06:49 +0200 Subject: [PATCH 3/4] Support sandboxed launch handoff and document translation review --- Core-Monitor/Core_MonitorApp.swift | 49 ++++++++++++++----- ...CoreMonitorSingleInstancePolicyTests.swift | 13 +++++ docs/development/runtime-safety.md | 11 ++++- 3 files changed, 60 insertions(+), 13 deletions(-) diff --git a/Core-Monitor/Core_MonitorApp.swift b/Core-Monitor/Core_MonitorApp.swift index eaa418bc..afd06f94 100644 --- a/Core-Monitor/Core_MonitorApp.swift +++ b/Core-Monitor/Core_MonitorApp.swift @@ -49,6 +49,22 @@ struct CoreMonitorDashboardHandoffRequest: Equatable { ] } + var notificationObject: String? { Self.notificationObject(for: userInfo) } + + static func notificationObject(for info: [AnyHashable: Any]?) -> String? { + guard let info, let data = try? JSONSerialization.data(withJSONObject: info) else { return nil } + return String(data: data, encoding: .utf8) + } + + static func notificationInfo(object: Any?, legacyUserInfo: [AnyHashable: Any]?) -> [AnyHashable: Any]? { + if let object = object as? String, object.utf8.count <= 4_096, + let data = object.data(using: .utf8), + let info = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + return info + } + return legacyUserInfo + } + static func acceptsAcknowledgement( userInfo: [AnyHashable: Any]?, bundleIdentifier: String, requestIdentifier: UUID, requesterPID: pid_t @@ -440,17 +456,19 @@ final class CoreMonitorApplicationDelegate: NSObject, NSApplicationDelegate { distributedDashboardRequestObserver = DistributedNotificationCenter.default().addObserver( forName: Self.openDashboardRequestNotification, - object: bundleIdentifier, + object: nil, queue: .main ) { [weak self] notification in + let info = CoreMonitorDashboardHandoffRequest.notificationInfo( + object: notification.object, legacyUserInfo: notification.userInfo + ) guard CoreMonitorDashboardHandoffRequest.accepts( - userInfo: notification.userInfo, + userInfo: info, expectedBundleIdentifier: bundleIdentifier, currentProcessIdentifier: ProcessInfo.processInfo.processIdentifier ) else { return } - let info = notification.userInfo Task { @MainActor [weak self] in guard let self else { return } let requestID = info?["requestIdentifier"] as? String @@ -458,10 +476,11 @@ final class CoreMonitorApplicationDelegate: NSObject, NSApplicationDelegate { openDashboard() lastDashboardRequestIdentifier = requestID } - if requestID != nil, dashboardController?.isDashboardVisible == true { + if requestID != nil, dashboardController?.isDashboardVisible == true, + let object = CoreMonitorDashboardHandoffRequest.notificationObject(for: info) { DistributedNotificationCenter.default().postNotificationName( - Self.dashboardAcknowledgementNotification, object: bundleIdentifier, - userInfo: info, deliverImmediately: true + Self.dashboardAcknowledgementNotification, object: object, + userInfo: nil, deliverImmediately: true ) } } @@ -565,10 +584,12 @@ final class CoreMonitorApplicationDelegate: NSObject, NSApplicationDelegate { handoffTask = Task { @MainActor [weak self] in guard let self else { return } let observer = DistributedNotificationCenter.default().addObserver( - forName: Self.dashboardAcknowledgementNotification, object: bundleIdentifier, queue: .main + forName: Self.dashboardAcknowledgementNotification, object: nil, queue: .main ) { [weak self] notification in guard CoreMonitorDashboardHandoffRequest.acceptsAcknowledgement( - userInfo: notification.userInfo, bundleIdentifier: bundleIdentifier, + userInfo: CoreMonitorDashboardHandoffRequest.notificationInfo( + object: notification.object, legacyUserInfo: notification.userInfo + ), bundleIdentifier: bundleIdentifier, requestIdentifier: requestID, requesterPID: currentPID ) else { return } Task { @MainActor [weak self] in self?.acknowledgedHandoff = requestID } @@ -604,10 +625,14 @@ final class CoreMonitorApplicationDelegate: NSObject, NSApplicationDelegate { let request = CoreMonitorDashboardHandoffRequest(bundleIdentifier: bundleIdentifier, targetProcessIdentifier: target.processIdentifier, requestIdentifier: requestID, requesterProcessIdentifier: currentPID) - DistributedNotificationCenter.default().postNotificationName( - Self.openDashboardRequestNotification, object: bundleIdentifier, - userInfo: request.userInfo, deliverImmediately: true - ) + // App Sandbox requires a nil userInfo dictionary. The + // object is a bounded JSON string with the same routing IDs. + if let object = request.notificationObject { + DistributedNotificationCenter.default().postNotificationName( + Self.openDashboardRequestNotification, object: object, + userInfo: nil, deliverImmediately: true + ) + } if target.isFinishedLaunching { _ = application.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) } diff --git a/Core-MonitorTests/CoreMonitorSingleInstancePolicyTests.swift b/Core-MonitorTests/CoreMonitorSingleInstancePolicyTests.swift index d45e3c7e..1dc03436 100644 --- a/Core-MonitorTests/CoreMonitorSingleInstancePolicyTests.swift +++ b/Core-MonitorTests/CoreMonitorSingleInstancePolicyTests.swift @@ -65,4 +65,17 @@ final class CoreMonitorSingleInstancePolicyTests: XCTestCase { ) ) } + + func testHandoffRoundTripsInSandboxCompatibleObjectWithoutUserInfo() throws { + let request = CoreMonitorDashboardHandoffRequest(bundleIdentifier: "CoreTools.Core-Monitor", + targetProcessIdentifier: 901, requesterProcessIdentifier: 900) + let object = try XCTUnwrap(request.notificationObject) + let info = CoreMonitorDashboardHandoffRequest.notificationInfo(object: object, legacyUserInfo: nil) + XCTAssertTrue(CoreMonitorDashboardHandoffRequest.accepts(userInfo: info, + expectedBundleIdentifier: request.bundleIdentifier, currentProcessIdentifier: 901)) + XCTAssertTrue(CoreMonitorDashboardHandoffRequest.acceptsAcknowledgement(userInfo: info, + bundleIdentifier: request.bundleIdentifier, requestIdentifier: request.requestIdentifier, requesterPID: 900)) + XCTAssertNil(CoreMonitorDashboardHandoffRequest.notificationInfo(object: "invalid", legacyUserInfo: nil)) + XCTAssertNil(CoreMonitorDashboardHandoffRequest.notificationInfo(object: String(repeating: "x", count: 4_097), legacyUserInfo: nil)) + } } diff --git a/docs/development/runtime-safety.md b/docs/development/runtime-safety.md index 5842aabe..efaec658 100644 --- a/docs/development/runtime-safety.md +++ b/docs/development/runtime-safety.md @@ -13,12 +13,21 @@ The watchdog depends on a running helper and responsive hardware calls. It does - Stopping or restarting a sampling session invalidates outstanding results. Turning off process insights also prevents old process results from repopulating the snapshot. - CPU fallback values and network baselines belong to the sampling queue. Published UI snapshots are accessed on the main thread. - Network rates use 64-bit counters per interface. Newly added or reset interfaces establish a new baseline; one interface disappearing does not zero the others' rates. +- Disk process counters include the kernel process start time. A reused PID starts a new baseline even when its counters exceed those of the previous process. +- Privacy changes reach alert redaction and persistence on the main run loop, including changes published by a background thread. + +## Single-instance startup + +A per-user application-support file holds a kernel lock for the primary app's lifetime. Its PID metadata is used only for routing: a stale PID never prevents acquiring a released lock. Other launches retry a dashboard request until the owner acknowledges it, or take over if the owner exits. A nonresponding owner produces an explicit startup message instead of a second monitoring instance. Requests use a string object and no notification dictionary for App Sandbox compatibility. + +Quit older builds before launching this version: builds without the new lock protocol cannot participate in its election. The lock file must not be deleted while the app is running. ## Windows, language, weather, and alerts - Closing the dashboard preserves its controller and frame for reopening. Existing off-screen recovery still applies after display changes. -- Battery descriptions and number/date formatting follow the selected app locale. Existing translations are reused; the new battery phrases include English, Swedish, German, French, and Spanish. Other missing translations use their English source text. Duration units use the platform formatter. +- Battery descriptions and number/date formatting follow the selected app locale. All nine battery strings have entries for the 112 advertised locales. Newly added or corrected translations are marked `needs_review` in the catalog for native-speaker review; automated tests verify compiled coverage and formatting placeholders, not linguistic accuracy. Duration units use the platform formatter. - A failed weather refresh may retain a cached reading only while it is younger than the configured refresh interval. Older readings give way to an error or an available fallback provider. +- Weather place names reuse the most recent successful lookup for up to one hour and within 500 metres, provided the app locale is unchanged. Concurrent matching lookups share one request. The cache is in memory only; failed lookups can retry. The app sends location to Apple for weather and place names, while the developer does not receive it. - Repeated desktop notifications use one identifier per alert rule. In-app history, severity changes, and repeat cooldowns retain their existing behavior. Physical fan tests and manual visual inspection are not replaced by the automated suite. From 8e4a0af7bfb7d480b65a6eaab8014f4da31d6fad Mon Sep 17 00:00:00 2001 From: SmokeStudios <290871035+OGSmokeStudios@users.noreply.github.com> Date: Sat, 19 Sep 2026 12:08:23 +0200 Subject: [PATCH 4/4] Ignore acknowledgements from a previous launch owner --- Core-Monitor/Core_MonitorApp.swift | 28 +++++++++++++------ ...CoreMonitorSingleInstancePolicyTests.swift | 10 ++++--- 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/Core-Monitor/Core_MonitorApp.swift b/Core-Monitor/Core_MonitorApp.swift index afd06f94..4f8c5f9a 100644 --- a/Core-Monitor/Core_MonitorApp.swift +++ b/Core-Monitor/Core_MonitorApp.swift @@ -67,11 +67,12 @@ struct CoreMonitorDashboardHandoffRequest: Equatable { static func acceptsAcknowledgement( userInfo: [AnyHashable: Any]?, bundleIdentifier: String, - requestIdentifier: UUID, requesterPID: pid_t + requestIdentifier: UUID, requesterPID: pid_t, ownerPID: pid_t ) -> Bool { userInfo?[bundleIdentifierKey] as? String == bundleIdentifier && userInfo?["requestIdentifier"] as? String == requestIdentifier.uuidString && - (userInfo?["requesterProcessIdentifier"] as? NSNumber)?.int32Value == requesterPID + (userInfo?["requesterProcessIdentifier"] as? NSNumber)?.int32Value == requesterPID && + (userInfo?[targetProcessIdentifierKey] as? NSNumber)?.int32Value == ownerPID } static func accepts( @@ -231,6 +232,7 @@ final class CoreMonitorApplicationDelegate: NSObject, NSApplicationDelegate { private var instanceLock: SingleInstanceLock? private var handoffTask: Task? private var acknowledgedHandoff: UUID? + private var handoffTargetPID: pid_t? private var lastDashboardRequestIdentifier: String? private var pendingInitialDashboardAttempts: [DispatchWorkItem] = [] private var quitShortcutMonitor: Any? @@ -586,13 +588,17 @@ final class CoreMonitorApplicationDelegate: NSObject, NSApplicationDelegate { let observer = DistributedNotificationCenter.default().addObserver( forName: Self.dashboardAcknowledgementNotification, object: nil, queue: .main ) { [weak self] notification in - guard CoreMonitorDashboardHandoffRequest.acceptsAcknowledgement( - userInfo: CoreMonitorDashboardHandoffRequest.notificationInfo( - object: notification.object, legacyUserInfo: notification.userInfo - ), bundleIdentifier: bundleIdentifier, - requestIdentifier: requestID, requesterPID: currentPID - ) else { return } - Task { @MainActor [weak self] in self?.acknowledgedHandoff = requestID } + let info = CoreMonitorDashboardHandoffRequest.notificationInfo( + object: notification.object, legacyUserInfo: notification.userInfo + ) + Task { @MainActor [weak self] in + guard let self, let ownerPID = handoffTargetPID, + CoreMonitorDashboardHandoffRequest.acceptsAcknowledgement( + userInfo: info, bundleIdentifier: bundleIdentifier, + requestIdentifier: requestID, requesterPID: currentPID, ownerPID: ownerPID + ) else { return } + acknowledgedHandoff = requestID + } } defer { DistributedNotificationCenter.default().removeObserver(observer) } @@ -618,6 +624,10 @@ final class CoreMonitorApplicationDelegate: NSObject, NSApplicationDelegate { if let target = CoreMonitorSingleInstancePolicy.handoffTarget( from: instances, currentPID: currentPID, ownerPID: lock.ownerPID ), let application = applications.first(where: { $0.processIdentifier == target.processIdentifier }) { + if handoffTargetPID != target.processIdentifier { + handoffTargetPID = target.processIdentifier + acknowledgedHandoff = nil + } if acknowledgedHandoff == requestID { NSApp.terminate(nil) return diff --git a/Core-MonitorTests/CoreMonitorSingleInstancePolicyTests.swift b/Core-MonitorTests/CoreMonitorSingleInstancePolicyTests.swift index 1dc03436..88060d7c 100644 --- a/Core-MonitorTests/CoreMonitorSingleInstancePolicyTests.swift +++ b/Core-MonitorTests/CoreMonitorSingleInstancePolicyTests.swift @@ -20,11 +20,13 @@ final class CoreMonitorSingleInstancePolicyTests: XCTestCase { let request = CoreMonitorDashboardHandoffRequest(bundleIdentifier: "CoreTools.Core-Monitor", targetProcessIdentifier: 901, requesterProcessIdentifier: 900) XCTAssertTrue(CoreMonitorDashboardHandoffRequest.acceptsAcknowledgement(userInfo: request.userInfo, - bundleIdentifier: request.bundleIdentifier, requestIdentifier: request.requestIdentifier, requesterPID: 900)) + bundleIdentifier: request.bundleIdentifier, requestIdentifier: request.requestIdentifier, requesterPID: 900, ownerPID: 901)) XCTAssertFalse(CoreMonitorDashboardHandoffRequest.acceptsAcknowledgement(userInfo: request.userInfo, - bundleIdentifier: request.bundleIdentifier, requestIdentifier: UUID(), requesterPID: 900)) + bundleIdentifier: request.bundleIdentifier, requestIdentifier: UUID(), requesterPID: 900, ownerPID: 901)) XCTAssertFalse(CoreMonitorDashboardHandoffRequest.acceptsAcknowledgement(userInfo: request.userInfo, - bundleIdentifier: request.bundleIdentifier, requestIdentifier: request.requestIdentifier, requesterPID: 902)) + bundleIdentifier: request.bundleIdentifier, requestIdentifier: request.requestIdentifier, requesterPID: 902, ownerPID: 901)) + XCTAssertFalse(CoreMonitorDashboardHandoffRequest.acceptsAcknowledgement(userInfo: request.userInfo, + bundleIdentifier: request.bundleIdentifier, requestIdentifier: request.requestIdentifier, requesterPID: 900, ownerPID: 902)) } func testDashboardHandoffRequestRequiresExpectedBundleAndTargetPID() { @@ -74,7 +76,7 @@ final class CoreMonitorSingleInstancePolicyTests: XCTestCase { XCTAssertTrue(CoreMonitorDashboardHandoffRequest.accepts(userInfo: info, expectedBundleIdentifier: request.bundleIdentifier, currentProcessIdentifier: 901)) XCTAssertTrue(CoreMonitorDashboardHandoffRequest.acceptsAcknowledgement(userInfo: info, - bundleIdentifier: request.bundleIdentifier, requestIdentifier: request.requestIdentifier, requesterPID: 900)) + bundleIdentifier: request.bundleIdentifier, requestIdentifier: request.requestIdentifier, requesterPID: 900, ownerPID: 901)) XCTAssertNil(CoreMonitorDashboardHandoffRequest.notificationInfo(object: "invalid", legacyUserInfo: nil)) XCTAssertNil(CoreMonitorDashboardHandoffRequest.notificationInfo(object: String(repeating: "x", count: 4_097), legacyUserInfo: nil)) }