Skip to content

Commit 691478c

Browse files
authored
Add pt-BR + localize redesigned views + locale parity guard (#101)
Supersedes #76. - Adds Brazilian Portuguese (pt-BR) localization with 227 keys at parity with en. - Localizes the post-#80 dashboard surface (DashboardView, MainWindow, CategoryDetailView, AppListView, AppFilesView, OrphanListView, OnboardingView, SettingsView, AppearancePill). - Settings → General → Language picker with System Default + every bundled language. Mutates only AppleLanguages on apply (AppleLocale preserved so number/date/currency formatting follows the host locale). - Extracts AppLanguage + AppLanguagePreferences into Models/. - Relaunch prompt after language switch (AppleLanguages is consumed at startup) via /usr/bin/open -n. - AppState gains injection points (performStartupTasks, locationsProvider, appFileScanner) so the app-file scan path is unit-testable. - New PureMacTests target with three test cases: LocalizationFilesTests (locale key-parity guard), AppLanguagePreferencesTests, AppStateTests. project.yml registers the test target + shared scheme. Rebased onto post-#103 main; AppState.swift @published additions kept side-by-side.
1 parent 6fe3f0f commit 691478c

24 files changed

Lines changed: 2406 additions & 642 deletions

PureMac.xcodeproj/project.pbxproj

Lines changed: 129 additions & 6 deletions
Large diffs are not rendered by default.
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<Scheme
3+
LastUpgradeVersion = "1640"
4+
version = "1.7">
5+
<BuildAction
6+
parallelizeBuildables = "YES"
7+
buildImplicitDependencies = "YES"
8+
runPostActionsOnFailure = "NO">
9+
<BuildActionEntries>
10+
<BuildActionEntry
11+
buildForTesting = "YES"
12+
buildForRunning = "YES"
13+
buildForProfiling = "YES"
14+
buildForArchiving = "YES"
15+
buildForAnalyzing = "YES">
16+
<BuildableReference
17+
BuildableIdentifier = "primary"
18+
BlueprintIdentifier = "B19E38DEAA0144A77120F39C"
19+
BuildableName = "PureMac.app"
20+
BlueprintName = "PureMac"
21+
ReferencedContainer = "container:PureMac.xcodeproj">
22+
</BuildableReference>
23+
</BuildActionEntry>
24+
<BuildActionEntry
25+
buildForTesting = "YES"
26+
buildForRunning = "NO"
27+
buildForProfiling = "NO"
28+
buildForArchiving = "NO"
29+
buildForAnalyzing = "NO">
30+
<BuildableReference
31+
BuildableIdentifier = "primary"
32+
BlueprintIdentifier = "FBA8B0DE95746207A043B802"
33+
BuildableName = "PureMacTests.xctest"
34+
BlueprintName = "PureMacTests"
35+
ReferencedContainer = "container:PureMac.xcodeproj">
36+
</BuildableReference>
37+
</BuildActionEntry>
38+
</BuildActionEntries>
39+
</BuildAction>
40+
<TestAction
41+
buildConfiguration = "Debug"
42+
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
43+
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
44+
shouldUseLaunchSchemeArgsEnv = "YES"
45+
onlyGenerateCoverageForSpecifiedTargets = "NO">
46+
<MacroExpansion>
47+
<BuildableReference
48+
BuildableIdentifier = "primary"
49+
BlueprintIdentifier = "B19E38DEAA0144A77120F39C"
50+
BuildableName = "PureMac.app"
51+
BlueprintName = "PureMac"
52+
ReferencedContainer = "container:PureMac.xcodeproj">
53+
</BuildableReference>
54+
</MacroExpansion>
55+
<Testables>
56+
<TestableReference
57+
skipped = "NO"
58+
parallelizable = "NO">
59+
<BuildableReference
60+
BuildableIdentifier = "primary"
61+
BlueprintIdentifier = "FBA8B0DE95746207A043B802"
62+
BuildableName = "PureMacTests.xctest"
63+
BlueprintName = "PureMacTests"
64+
ReferencedContainer = "container:PureMac.xcodeproj">
65+
</BuildableReference>
66+
</TestableReference>
67+
</Testables>
68+
<CommandLineArguments>
69+
</CommandLineArguments>
70+
</TestAction>
71+
<LaunchAction
72+
buildConfiguration = "Debug"
73+
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
74+
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
75+
launchStyle = "0"
76+
useCustomWorkingDirectory = "NO"
77+
ignoresPersistentStateOnLaunch = "NO"
78+
debugDocumentVersioning = "YES"
79+
debugServiceExtension = "internal"
80+
allowLocationSimulation = "YES">
81+
<BuildableProductRunnable
82+
runnableDebuggingMode = "0">
83+
<BuildableReference
84+
BuildableIdentifier = "primary"
85+
BlueprintIdentifier = "B19E38DEAA0144A77120F39C"
86+
BuildableName = "PureMac.app"
87+
BlueprintName = "PureMac"
88+
ReferencedContainer = "container:PureMac.xcodeproj">
89+
</BuildableReference>
90+
</BuildableProductRunnable>
91+
</LaunchAction>
92+
<ProfileAction
93+
buildConfiguration = "Release"
94+
shouldUseLaunchSchemeArgsEnv = "YES"
95+
savedToolIdentifier = ""
96+
useCustomWorkingDirectory = "NO"
97+
debugDocumentVersioning = "YES">
98+
<BuildableProductRunnable
99+
runnableDebuggingMode = "0">
100+
<BuildableReference
101+
BuildableIdentifier = "primary"
102+
BlueprintIdentifier = "B19E38DEAA0144A77120F39C"
103+
BuildableName = "PureMac.app"
104+
BlueprintName = "PureMac"
105+
ReferencedContainer = "container:PureMac.xcodeproj">
106+
</BuildableReference>
107+
</BuildableProductRunnable>
108+
</ProfileAction>
109+
<AnalyzeAction
110+
buildConfiguration = "Debug">
111+
</AnalyzeAction>
112+
<ArchiveAction
113+
buildConfiguration = "Release"
114+
revealArchiveInOrganizer = "YES">
115+
</ArchiveAction>
116+
</Scheme>

PureMac/Models/AppLanguage.swift

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import Foundation
2+
3+
enum AppLanguage: String, CaseIterable, Identifiable {
4+
case system = "system"
5+
case english = "en"
6+
case spanish = "es"
7+
case japanese = "ja"
8+
case arabic = "ar"
9+
case portugueseBrazil = "pt-BR"
10+
case simplifiedChinese = "zh-Hans"
11+
case traditionalChinese = "zh-Hant"
12+
13+
static let preferenceKey = "settings.general.appLanguage"
14+
15+
var id: String { rawValue }
16+
17+
var displayName: String {
18+
switch self {
19+
case .system: return "System Default"
20+
case .english: return "English"
21+
case .spanish: return "Spanish"
22+
case .japanese: return "Japanese"
23+
case .arabic: return "Arabic"
24+
case .portugueseBrazil: return "Portuguese (Brazil)"
25+
case .simplifiedChinese: return "Chinese (Simplified)"
26+
case .traditionalChinese: return "Chinese (Traditional)"
27+
}
28+
}
29+
30+
static var current: AppLanguage {
31+
if let selectedLanguage = UserDefaults.standard.string(forKey: preferenceKey),
32+
let language = AppLanguage(rawValue: selectedLanguage) {
33+
return language
34+
}
35+
36+
guard let bundleIdentifier = Bundle.main.bundleIdentifier,
37+
let appDefaults = UserDefaults.standard.persistentDomain(forName: bundleIdentifier),
38+
let preferredLanguages = appDefaults["AppleLanguages"] as? [String],
39+
let preferredLanguage = preferredLanguages.first else {
40+
return .system
41+
}
42+
43+
let normalized = preferredLanguage.replacingOccurrences(of: "_", with: "-")
44+
return allCases.first { $0.rawValue == normalized } ?? .system
45+
}
46+
}
47+
48+
enum AppLanguagePreferences {
49+
static func apply(_ language: AppLanguage, defaults: UserDefaults = .standard) {
50+
if language == .system {
51+
defaults.removeObject(forKey: "AppleLanguages")
52+
} else {
53+
defaults.set([language.rawValue], forKey: "AppleLanguages")
54+
}
55+
}
56+
}

PureMac/ViewModels/AppState.swift

Lines changed: 57 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ enum AppSection: Hashable {
1313

1414
@MainActor
1515
final class AppState: ObservableObject {
16+
typealias AppFileScanner = @MainActor (
17+
_ app: InstalledApp,
18+
_ locations: Locations,
19+
_ completion: @escaping (Set<URL>) -> Void
20+
) -> Void
1621

1722
// MARK: - Scan / Clean State
1823

@@ -45,12 +50,15 @@ final class AppState: ObservableObject {
4550
@Published var isScanningAppFiles: Bool = false
4651
@Published var removalError: String?
4752
@Published var removalNeedsFullDiskAccess = false
53+
@Published var appFileScanLocationCount: Int = 0
4854

4955
// MARK: - Services
5056

5157
var scheduler = SchedulerService()
5258
private let scanEngine = ScanEngine()
5359
private let cleaningEngine = CleaningEngine()
60+
private let locationsProvider: () -> Locations
61+
private let appFileScanner: AppFileScanner
5462

5563
// MARK: - Computed
5664

@@ -70,21 +78,37 @@ final class AppState: ObservableObject {
7078
allResults.flatMap { $0.items }.filter { isItemSelected($0) }.reduce(0) { $0 + $1.size }
7179
}
7280

81+
var currentAppFileSearchLocationCount: Int {
82+
if isScanningAppFiles && appFileScanLocationCount > 0 {
83+
return appFileScanLocationCount
84+
}
85+
return discoveredFiles.count
86+
}
87+
7388
// MARK: - Init
7489

75-
init() {
76-
loadDiskInfo()
77-
checkFullDiskAccess()
78-
loadInstalledApps()
79-
scheduler.setTrigger { [weak self] in
80-
await self?.runScheduledScan()
81-
}
82-
// Only arm the scheduler once onboarding has completed. Before the
83-
// first launch the defaults plist may have been attacker-planted with
84-
// autoClean=true - waiting for onboarding ensures a human consents to
85-
// auto-clean before we start honoring it.
86-
if UserDefaults.standard.bool(forKey: "PureMac.OnboardingComplete") {
87-
scheduler.start()
90+
init(
91+
performStartupTasks: Bool = true,
92+
locationsProvider: @escaping () -> Locations = Locations.init,
93+
appFileScanner: @escaping AppFileScanner = AppState.defaultAppFileScanner
94+
) {
95+
self.locationsProvider = locationsProvider
96+
self.appFileScanner = appFileScanner
97+
98+
if performStartupTasks {
99+
loadDiskInfo()
100+
checkFullDiskAccess()
101+
loadInstalledApps()
102+
scheduler.setTrigger { [weak self] in
103+
await self?.runScheduledScan()
104+
}
105+
// Only arm the scheduler once onboarding has completed. Before
106+
// the first launch the defaults plist may have been
107+
// attacker-planted with autoClean=true; wait for human consent
108+
// via onboarding.
109+
if UserDefaults.standard.bool(forKey: "PureMac.OnboardingComplete") {
110+
scheduler.start()
111+
}
88112
}
89113
}
90114

@@ -105,21 +129,15 @@ final class AppState: ObservableObject {
105129
discoveredFiles = []
106130
selectedFiles = []
107131
isScanningAppFiles = true
108-
let locations = Locations()
109-
let appInfo = AppPathFinder.AppInfo(
110-
appName: app.appName,
111-
bundleIdentifier: app.bundleIdentifier,
112-
path: app.path,
113-
entitlements: nil,
114-
teamIdentifier: nil
115-
)
116-
let finder = AppPathFinder(appInfo: appInfo, locations: locations)
117-
finder.findPathsAsync { [weak self] urls in
132+
let locations = locationsProvider()
133+
appFileScanLocationCount = locations.appSearch.paths.count
134+
appFileScanner(app, locations) { [weak self] urls in
118135
guard let self else { return }
119136
let sorted = urls.sorted { $0.path < $1.path }
120137
self.discoveredFiles = sorted
121138
self.selectedFiles = urls
122139
self.isScanningAppFiles = false
140+
self.appFileScanLocationCount = 0
123141
}
124142
}
125143

@@ -762,4 +780,20 @@ final class AppState: ObservableObject {
762780

763781
UNUserNotificationCenter.current().add(request)
764782
}
783+
784+
private static func defaultAppFileScanner(
785+
app: InstalledApp,
786+
locations: Locations,
787+
completion: @escaping (Set<URL>) -> Void
788+
) {
789+
let appInfo = AppPathFinder.AppInfo(
790+
appName: app.appName,
791+
bundleIdentifier: app.bundleIdentifier,
792+
path: app.path,
793+
entitlements: nil,
794+
teamIdentifier: nil
795+
)
796+
let finder = AppPathFinder(appInfo: appInfo, locations: locations)
797+
finder.findPathsAsync(completion: completion)
798+
}
765799
}

PureMac/Views/Apps/AppFilesView.swift

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ struct AppFilesView: View {
3030

3131
if !appState.discoveredFiles.isEmpty {
3232
VStack(alignment: .trailing, spacing: 2) {
33-
Text("\(appState.discoveredFiles.count) files")
33+
Text(filesCountText(count: appState.discoveredFiles.count))
3434
.font(.callout)
3535
.foregroundStyle(.secondary)
3636
Text(ByteCountFormatter.string(fromByteCount: totalSelectedSize, countStyle: .file))
@@ -46,8 +46,8 @@ struct AppFilesView: View {
4646
if appState.isScanningAppFiles {
4747
VStack(spacing: 12) {
4848
Spacer()
49-
ProgressView("Scanning for related files...")
50-
Text("Checking \(appState.discoveredFiles.count) locations...")
49+
ProgressView(LocalizedStringKey("Scanning for related files..."))
50+
Text(checkingLocationsText(count: appState.currentAppFileSearchLocationCount))
5151
.font(.caption)
5252
.foregroundStyle(.secondary)
5353
Spacer()
@@ -57,7 +57,9 @@ struct AppFilesView: View {
5757
EmptyStateView(
5858
"No Related Files",
5959
systemImage: "checkmark.circle",
60-
description: "No additional files found for \(app.appName)."
60+
description: LocalizedStringKey(
61+
String(format: String(localized: "No additional files found for %@."), app.appName)
62+
)
6163
)
6264
} else {
6365
List(appState.discoveredFiles, id: \.self) { fileURL in
@@ -81,7 +83,7 @@ struct AppFilesView: View {
8183
Spacer()
8284

8385
if !appState.selectedFiles.isEmpty {
84-
Button("Remove \(appState.selectedFiles.count) files (\(ByteCountFormatter.string(fromByteCount: totalSelectedSize, countStyle: .file)))", role: .destructive) {
86+
Button(removeFilesLabel, role: .destructive) {
8587
appState.removeSelectedFiles()
8688
}
8789
.buttonStyle(.borderedProminent)
@@ -116,6 +118,22 @@ struct AppFilesView: View {
116118
}
117119
}
118120

121+
private func filesCountText(count: Int) -> String {
122+
String(format: String(localized: "%lld files"), Int64(count))
123+
}
124+
125+
private func checkingLocationsText(count: Int) -> String {
126+
String(format: String(localized: "Checking %lld locations..."), Int64(count))
127+
}
128+
129+
private var removeFilesLabel: String {
130+
String(
131+
format: String(localized: "Remove %lld files (%@)"),
132+
Int64(appState.selectedFiles.count),
133+
ByteCountFormatter.string(fromByteCount: totalSelectedSize, countStyle: .file)
134+
)
135+
}
136+
119137
private func fileSelectionBinding(for url: URL) -> Binding<Bool> {
120138
Binding(
121139
get: { appState.selectedFiles.contains(url) },
@@ -223,7 +241,12 @@ struct FileRow: View {
223241
.onHover { hovering in
224242
withAnimation(.easeInOut(duration: 0.15)) { isHovering = hovering }
225243
}
226-
.alert("Remove \(fileURL.lastPathComponent)?", isPresented: $showConfirmation) {
244+
.alert(
245+
Text(
246+
String(format: String(localized: "Remove %@?"), fileURL.lastPathComponent)
247+
),
248+
isPresented: $showConfirmation
249+
) {
227250
Button("Cancel", role: .cancel) {}
228251
Button("Remove", role: .destructive) { onRemove() }
229252
} message: {

0 commit comments

Comments
 (0)