diff --git a/gitrelay.xcodeproj/project.pbxproj b/gitrelay.xcodeproj/project.pbxproj index 7f14601..50fc79f 100644 --- a/gitrelay.xcodeproj/project.pbxproj +++ b/gitrelay.xcodeproj/project.pbxproj @@ -613,7 +613,6 @@ ENABLE_USER_SELECTED_FILES = readonly; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = gitrelay/Info.plist; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", @@ -645,7 +644,6 @@ ENABLE_USER_SELECTED_FILES = readonly; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = gitrelay/Info.plist; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", @@ -799,7 +797,7 @@ SKIP_INSTALL = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES; - SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; }; @@ -829,7 +827,7 @@ SKIP_INSTALL = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES; - SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; }; diff --git a/gitrelay/Models/RepoAccountLine.swift b/gitrelay/Models/RepoAccountLine.swift index eee485e..ed3fbe0 100644 --- a/gitrelay/Models/RepoAccountLine.swift +++ b/gitrelay/Models/RepoAccountLine.swift @@ -11,7 +11,7 @@ nonisolated struct RepoAccountLine: Equatable, Sendable { let names: [String] var text: String { - String.loc("Account · \(names.joined(separator: " · "))") + String(format: String.loc("Account · %@"), names.joined(separator: " · ")) } /// Nil when nothing was resolved, which is the quiet case. diff --git a/gitrelay/Services/AppLocalization.swift b/gitrelay/Services/AppLocalization.swift index c7f89d1..b57ba91 100644 --- a/gitrelay/Services/AppLocalization.swift +++ b/gitrelay/Services/AppLocalization.swift @@ -16,14 +16,14 @@ enum AppLocalization { private static let lock = NSLock() nonisolated(unsafe) private static var activeOverride: Override? - static func apply(_ preference: AppLanguagePreference, in bundle: Bundle = .main) { + nonisolated static func apply(_ preference: AppLanguagePreference, in bundle: Bundle = .main) { let resolved = resolveOverride(preference, in: bundle) lock.lock() activeOverride = resolved lock.unlock() } - static func string(for key: String.LocalizationValue) -> String { + nonisolated static func string(for key: String.LocalizationValue) -> String { lock.lock() let active = activeOverride lock.unlock() @@ -31,7 +31,7 @@ enum AppLocalization { return String(localized: key, bundle: active.bundle, locale: active.locale) } - private static func resolveOverride( + private nonisolated static func resolveOverride( _ preference: AppLanguagePreference, in bundle: Bundle ) -> Override? { @@ -48,7 +48,7 @@ enum AppLocalization { return Override(locale: preference.locale, bundle: languageBundle) } - private static func targetLocalization( + private nonisolated static func targetLocalization( _ preference: AppLanguagePreference, in bundle: Bundle ) -> String? { @@ -61,7 +61,7 @@ enum AppLocalization { ).first } - static func localizationName(for code: String?, available: [String]) -> String? { + nonisolated static func localizationName(for code: String?, available: [String]) -> String? { guard let code, !code.isEmpty else { return nil } if let exact = available.first(where: { $0.caseInsensitiveCompare(code) == .orderedSame }) { return exact @@ -78,7 +78,7 @@ enum AppLocalization { /// Clears any in-session catalog override. Unit tests should call this after /// exercising ``apply(_:)`` so later tests see the default catalog. - static func resetOverride() { + nonisolated static func resetOverride() { lock.lock() activeOverride = nil lock.unlock() @@ -91,7 +91,7 @@ extension String { /// Prefer this over `String(localized:)` for anything a running window can /// show, so switching language in Settings does not leave the sentence in /// the previous language until the next launch. - static func loc(_ key: String.LocalizationValue) -> String { + nonisolated static func loc(_ key: String.LocalizationValue) -> String { AppLocalization.string(for: key) } } diff --git a/gitrelay/Services/GitLabTargetAPIClient.swift b/gitrelay/Services/GitLabTargetAPIClient.swift index 797dbb2..3d5f1a7 100644 --- a/gitrelay/Services/GitLabTargetAPIClient.swift +++ b/gitrelay/Services/GitLabTargetAPIClient.swift @@ -81,7 +81,7 @@ struct GitLabTargetAPIClient: TargetProviderAPIClient { ) guard let match = namespaces.first(where: { $0.full_path.lowercased() == owner.lowercased() }) else { throw TargetProviderAPIError.validation( - String.loc("No GitLab group or user namespace named \(owner) is visible to this token.") + String(format: String.loc("No GitLab group or user namespace named %@ is visible to this token."), owner) ) } return match.id diff --git a/gitrelay/Services/IntegrityVerifier.swift b/gitrelay/Services/IntegrityVerifier.swift index 11a9a12..5fe311a 100644 --- a/gitrelay/Services/IntegrityVerifier.swift +++ b/gitrelay/Services/IntegrityVerifier.swift @@ -25,7 +25,7 @@ final class IntegrityVerifier { func run() async { emit(.started) let branch = RepoConfig.normalizedBranch(repo.defaultBranch) - log(String.loc("Integrity verification started (branch: \(branch))...")) + log(String(format: String.loc("Integrity verification started (branch: %@)..."), branch)) let srcURL = authenticatedURL(url: repo.srcURL, auth: repo.srcAuth) let srcEnv = buildEnv(for: repo.srcAuth) @@ -36,14 +36,14 @@ final class IntegrityVerifier { let message = enabledTargets.isEmpty ? SyncEngineError.noEnabledTargets.localizedDescription : "No git remote targets to verify (filesystem archive targets skipped)" - log(String.loc("Error: \(message)")) + log(String(format: String.loc("Error: %@"), message)) record.finishedAt = Date() emit(.failed(message, record)) return } if verifiableTargets.count < enabledTargets.count { - log(String.loc("Skipped \(enabledTargets.count - verifiableTargets.count) filesystem archive targets.")) + log(String(format: String.loc("Skipped %lld filesystem archive targets."), enabledTargets.count - verifiableTargets.count)) } do { @@ -115,7 +115,7 @@ final class IntegrityVerifier { case .diverged(let detail): targetResult.succeeded = false targetResult.error = detail.summary - targetLog(String.loc("⚠ Content divergence detected: \(detail.summary)")) + targetLog(String(format: String.loc("⚠ Content divergence detected: %@"), detail.summary)) targetLog(" src tree: \(detail.srcTreeHash.truncatingSHA)") targetLog(" dst tree: \(detail.dstTreeHash.truncatingSHA)") divergedDetails.append(detail) @@ -124,7 +124,7 @@ final class IntegrityVerifier { targetResult.succeeded = false let redacted = SyncEngine.redactCredentials(message) targetResult.error = redacted - targetLog(String.loc("Inconclusive: \(redacted)")) + targetLog(String(format: String.loc("Inconclusive: %@"), redacted)) inconclusiveMessages.append("\(target.displayLabel): \(redacted)") } @@ -136,7 +136,7 @@ final class IntegrityVerifier { if let firstDiverged = divergedDetails.first { record.succeeded = false let summary = multiTargetDivergenceSummary(details: divergedDetails) - log(String.loc("⚠ Content divergence detected: \(summary)")) + log(String(format: String.loc("⚠ Content divergence detected: %@"), summary)) var detail = firstDiverged if divergedDetails.count > 1 { detail.summaryOverride = summary @@ -148,13 +148,13 @@ final class IntegrityVerifier { if !inconclusiveMessages.isEmpty { record.succeeded = false let message = inconclusiveMessages.joined(separator: "; ") - log(String.loc("Inconclusive: \(message)")) + log(String(format: String.loc("Inconclusive: %@"), message)) emit(.failed(message, record)) return } record.succeeded = matchedCount == verifiableTargets.count - log(String.loc("All \(matchedCount) targets passed verification.")) + log(String(format: String.loc("All %lld targets passed verification."), matchedCount)) emit(.completed(.matched(reason: .identicalCommitSHA), record)) } catch GitError.cancelled { @@ -164,7 +164,7 @@ final class IntegrityVerifier { } catch { let message = SyncEngine.redactCredentials(error.localizedDescription) - log(String.loc("Error: \(message)")) + log(String(format: String.loc("Error: %@"), message)) record.finishedAt = Date() emit(.failed(message, record)) } @@ -178,7 +178,7 @@ final class IntegrityVerifier { private func multiTargetDivergenceSummary(details: [VerificationDecision.Detail]) -> String { guard details.count > 1 else { return details[0].summary } - return String.loc("\(details.count) targets have content divergence: \(details[0].summary)") + return String(format: String.loc("%lld targets have content divergence: %@"), details.count, details[0].summary) } private func prepareWorkRepo() async throws -> String { @@ -200,7 +200,7 @@ final class IntegrityVerifier { label: String, log: (String) -> Void ) async throws -> String { - log(String.loc("Fetching \(label) commit \(commitSHA.truncatingSHA)...")) + log(String(format: String.loc("Fetching %@ commit %@..."), label, commitSHA.truncatingSHA)) try await runner.fetchCommit( repoPath: workPath, remoteURL: remoteURL, diff --git a/gitrelay/Services/ProviderAPIClient.swift b/gitrelay/Services/ProviderAPIClient.swift index 8b95ade..05f94bd 100644 --- a/gitrelay/Services/ProviderAPIClient.swift +++ b/gitrelay/Services/ProviderAPIClient.swift @@ -12,18 +12,20 @@ nonisolated enum ProviderAPIError: LocalizedError { switch self { case .unauthorized(let msg): let base = String.loc("Authentication failed (401): Make sure the token has not expired and has the correct scopes (repo + read:org for GitHub, read_api for GitLab)") - return msg.map { String.loc("\(base). Server message: \($0)") } ?? base + return msg.map { String(format: String.loc("%@. Server message: %@"), base, $0) } ?? base case .forbidden(let msg): - return msg.map { String.loc("Permission denied or rate limited (403): \($0)") } ?? String.loc("Permission denied or rate limited (403)") + return msg.map { String(format: String.loc("Permission denied or rate limited (403): %@"), $0) } + ?? String.loc("Permission denied or rate limited (403)") case .notFound(let msg): let base = String.loc("Resource not found (404): Check that the username, organization, or group name is correct") - return msg.map { String.loc("\(base). Server message: \($0)") } ?? base + return msg.map { String(format: String.loc("%@. Server message: %@"), base, $0) } ?? base case .network(let e): - return String.loc("Network request failed: \(e.localizedDescription)") + return String(format: String.loc("Network request failed: %@"), e.localizedDescription) case .decoding(let e): - return String.loc("Failed to parse response: \(e.localizedDescription)") + return String(format: String.loc("Failed to parse response: %@"), e.localizedDescription) case .http(let s, let m): - return m.map { String.loc("HTTP \(s): \($0)") } ?? String.loc("HTTP \(s)") + return m.map { String(format: String.loc("HTTP %@: %@"), String(s), $0) } + ?? String(format: String.loc("HTTP %@"), String(s)) } } } diff --git a/gitrelay/Services/TargetProviderAPIClient.swift b/gitrelay/Services/TargetProviderAPIClient.swift index 69cc79f..85521aa 100644 --- a/gitrelay/Services/TargetProviderAPIClient.swift +++ b/gitrelay/Services/TargetProviderAPIClient.swift @@ -8,8 +8,8 @@ nonisolated enum TargetNamespace: Hashable, Sendable { var displayLabel: String { switch self { case .currentUser: String.loc("Current User") - case .organization(let org): String.loc("Organization: \(org)") - case .adminForUser(let user): String.loc("Administrator → User: \(user)") + case .organization(let org): String(format: String.loc("Organization: %@"), org) + case .adminForUser(let user): String(format: String.loc("Administrator → User: %@"), user) } } } @@ -36,12 +36,22 @@ nonisolated enum TargetProviderAPIError: LocalizedError { var errorDescription: String? { switch self { - case .unauthorized(let m): return m.map { String.loc("Authentication failed (401): \($0)") } ?? String.loc("Authentication failed (401)") - case .forbidden(let m): return m.map { String.loc("Permission denied (403): \($0)") } ?? String.loc("Permission denied (403)") - case .validation(let m): return m.map { String.loc("Invalid parameters: \($0)") } ?? String.loc("Invalid parameters (422)") - case .network(let e): return String.loc("Network error: \(e.localizedDescription)") - case .decoding(let e): return String.loc("Failed to parse response: \(e.localizedDescription)") - case .http(let s, let m): return m.map { String.loc("HTTP \(s): \($0)") } ?? String.loc("HTTP \(s)") + case .unauthorized(let m): + return m.map { String(format: String.loc("Authentication failed (401): %@"), $0) } + ?? String.loc("Authentication failed (401)") + case .forbidden(let m): + return m.map { String(format: String.loc("Permission denied (403): %@"), $0) } + ?? String.loc("Permission denied (403)") + case .validation(let m): + return m.map { String(format: String.loc("Invalid parameters: %@"), $0) } + ?? String.loc("Invalid parameters (422)") + case .network(let e): + return String(format: String.loc("Network error: %@"), e.localizedDescription) + case .decoding(let e): + return String(format: String.loc("Failed to parse response: %@"), e.localizedDescription) + case .http(let s, let m): + return m.map { String(format: String.loc("HTTP %@: %@"), String(s), $0) } + ?? String(format: String.loc("HTTP %@"), String(s)) } } } diff --git a/gitrelay/ViewModels/AppViewModel.swift b/gitrelay/ViewModels/AppViewModel.swift index 15d7536..ad78f1a 100644 --- a/gitrelay/ViewModels/AppViewModel.swift +++ b/gitrelay/ViewModels/AppViewModel.swift @@ -271,7 +271,7 @@ final class AppViewModel { try MirrorStore.ensureBaseDirectoryExists() repos = try RepoStore.load() } catch { - errorMessage = String.loc("Failed to load repository configuration: \(error.localizedDescription)") + errorMessage = String(format: String.loc("Failed to load repository configuration: %@"), error.localizedDescription) } self.windowLayout.reconcileSelection(withExistingIDs: Set(repos.map(\.id))) @@ -709,7 +709,7 @@ final class AppViewModel { } try webhookListener.start() } catch { - errorMessage = String.loc("Failed to start the webhook listener: \(error.localizedDescription)") + errorMessage = String(format: String.loc("Failed to start the webhook listener: %@"), error.localizedDescription) webhookListener.stop() } } @@ -1423,7 +1423,7 @@ final class AppViewModel { try RepoStore.save(repos) refreshWidgetSnapshot() } catch { - errorMessage = String.loc("Failed to save repository configuration: \(error.localizedDescription)") + errorMessage = String(format: String.loc("Failed to save repository configuration: %@"), error.localizedDescription) } } diff --git a/gitrelay/Views/About/AboutView.swift b/gitrelay/Views/About/AboutView.swift index c70778a..0cd7f94 100644 --- a/gitrelay/Views/About/AboutView.swift +++ b/gitrelay/Views/About/AboutView.swift @@ -29,7 +29,7 @@ struct AboutView: View { Text(String.loc("GitRelay")) .font(.title3.weight(.semibold)) - Text(String.loc("Version \(version) (\(build))")) + Text(String(format: String.loc("Version %@ (%@)"), version, build)) .font(.callout) .foregroundStyle(.secondary) .textSelection(.enabled) diff --git a/gitrelay/Views/Browse/BrowseRemotePane.swift b/gitrelay/Views/Browse/BrowseRemotePane.swift index 0591e95..9fd1d98 100644 --- a/gitrelay/Views/Browse/BrowseRemotePane.swift +++ b/gitrelay/Views/Browse/BrowseRemotePane.swift @@ -69,7 +69,7 @@ struct BrowseRemotePane: View { private var headingSubtitle: String { switch vm.phase { case .submitting: - String.loc("Processing \(vm.submitProgress) / \(vm.submitTotal)") + String(format: String.loc("Processing %lld / %lld"), vm.submitProgress, vm.submitTotal) case .result: String.loc("Review the outcome, then add the pairs to the sync list.") case .connect, .selecting, .configureTarget: @@ -212,7 +212,7 @@ struct BrowseRemotePane: View { TextField("Search Names or Descriptions", text: $vm.searchText) .textFieldStyle(.plain) Spacer(minLength: DesignTokens.Spacing.sm) - Text(String.loc("\(vm.selectedIDs.count) selected")) + Text(String(format: String.loc("%lld selected"), vm.selectedIDs.count)) .font(.caption) .monospacedDigit() .foregroundStyle(.secondary) @@ -346,10 +346,10 @@ struct BrowseRemotePane: View { Text(vm.previewName(for: repo)) .font(.caption) .bold() - Text(String.loc("src: \(vm.sourceURL(for: repo))")) + Text(String(format: String.loc("src: %@"), vm.sourceURL(for: repo))) .font(.system(.caption2, design: .monospaced)) .foregroundStyle(.secondary) - Text(String.loc("dst: \(vm.previewURL(for: repo))")) + Text(String(format: String.loc("dst: %@"), vm.previewURL(for: repo))) .font(.system(.caption2, design: .monospaced)) .foregroundStyle(.secondary) } @@ -450,7 +450,7 @@ struct BrowseRemotePane: View { ProgressView(value: submitFraction) .progressViewStyle(.linear) .frame(maxWidth: DesignTokens.Layout.browseStepBarMaxWidth) - .accessibilityLabel(String.loc("Processing \(vm.submitProgress) / \(vm.submitTotal)")) + .accessibilityLabel(String(format: String.loc("Processing %lld / %lld"), vm.submitProgress, vm.submitTotal)) Spacer() } .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -477,14 +477,14 @@ struct BrowseRemotePane: View { return Form { Section { HStack(spacing: DesignTokens.Spacing.lg) { - Label(String.loc("Succeeded \(succeeded)"), systemImage: "checkmark.circle.fill") + Label(String(format: String.loc("Succeeded %lld"), succeeded), systemImage: "checkmark.circle.fill") .foregroundStyle(DesignTokens.StatusColor.success) if existed > 0 { - Label(String.loc("Reused \(existed)"), systemImage: "arrow.counterclockwise.circle.fill") + Label(String(format: String.loc("Reused %lld"), existed), systemImage: "arrow.counterclockwise.circle.fill") .foregroundStyle(DesignTokens.StatusColor.info) } if failed > 0 { - Label(String.loc("Failed \(failed)"), systemImage: "xmark.octagon.fill") + Label(String(format: String.loc("Failed %lld"), failed), systemImage: "xmark.octagon.fill") .foregroundStyle(DesignTokens.StatusColor.error) } } @@ -534,7 +534,7 @@ struct BrowseRemotePane: View { .disabled(!vm.canAdvanceToSelect || vm.isLoading) .keyboardShortcut(.return) case .selecting: - Button(String.loc("Next (\(vm.selectedIDs.count))")) { + Button(String(format: String.loc("Next (%lld)"), vm.selectedIDs.count)) { Task { await vm.advanceToTargetConfiguration() } } .buttonStyle(.borderedProminent) @@ -553,7 +553,7 @@ struct BrowseRemotePane: View { EmptyView() case .result: let count = vm.successfulConfigs.count - Button(String.loc("Add \(count) to Sync List")) { + Button(String(format: String.loc("Add %lld to Sync List"), count)) { let configs = vm.successfulConfigs vm.persistTokensForSuccessfulConfigs() appVM.addRepos(configs, triggerSync: true) diff --git a/gitrelay/Views/Detail/ReleaseMirrorStatusView.swift b/gitrelay/Views/Detail/ReleaseMirrorStatusView.swift index 5b56ebe..7dde84a 100644 --- a/gitrelay/Views/Detail/ReleaseMirrorStatusView.swift +++ b/gitrelay/Views/Detail/ReleaseMirrorStatusView.swift @@ -56,7 +56,7 @@ struct ReleaseMirrorStatusView: View { ProgressView() .controlSize(.small) } else if let lastSyncedAt = status.lastSyncedAt { - Text(String.loc("Last \(lastSyncedAt.formatted(date: .abbreviated, time: .shortened))")) + Text(String(format: String.loc("Last %@"), lastSyncedAt.formatted(date: .abbreviated, time: .shortened))) .font(.caption) .foregroundStyle(.secondary) } @@ -95,7 +95,7 @@ struct ReleaseMirrorStatusView: View { Text(tag.tagName) .font(.system(.body, design: .monospaced)) if tag.totalAssets > 0 { - Text(String.loc("\(tag.completedCount)/\(tag.totalAssets) assets")) + Text(String(format: String.loc("%lld/%lld assets"), tag.completedCount, tag.totalAssets)) .font(.caption) .foregroundStyle(.secondary) } else { diff --git a/gitrelay/Views/Detail/RepoDivergedRowView.swift b/gitrelay/Views/Detail/RepoDivergedRowView.swift index 804ee65..5f7916c 100644 --- a/gitrelay/Views/Detail/RepoDivergedRowView.swift +++ b/gitrelay/Views/Detail/RepoDivergedRowView.swift @@ -20,7 +20,7 @@ struct RepoDivergedRowView: View { .foregroundStyle(.secondary) .textSelection(.enabled) if let lastVerifiedAt { - Text(String.loc("Last verified: \(lastVerifiedAt.formatted(.relative(presentation: .named)))")) + Text(String(format: String.loc("Last verified: %@"), lastVerifiedAt.formatted(.relative(presentation: .named)))) .font(.caption) .foregroundStyle(.secondary) } diff --git a/gitrelay/Views/Detail/RepoFailureRowView.swift b/gitrelay/Views/Detail/RepoFailureRowView.swift index 647cec5..82f7ee7 100644 --- a/gitrelay/Views/Detail/RepoFailureRowView.swift +++ b/gitrelay/Views/Detail/RepoFailureRowView.swift @@ -20,7 +20,7 @@ struct RepoFailureRowView: View { .font(.callout) .fontWeight(.medium) if consecutiveFailureCount > 0 { - Text(String.loc("\(consecutiveFailureCount) consecutive failures")) + Text(String(format: String.loc("%lld consecutive failures"), consecutiveFailureCount)) .font(.caption) .foregroundStyle( consecutiveFailureCount >= 3 @@ -33,7 +33,7 @@ struct RepoFailureRowView: View { .foregroundStyle(.secondary) .textSelection(.enabled) if let lastSuccessfulSyncedAt { - Text(String.loc("Last success: \(lastSuccessfulSyncedAt.formatted(.relative(presentation: .named)))")) + Text(String(format: String.loc("Last success: %@"), lastSuccessfulSyncedAt.formatted(.relative(presentation: .named)))) .font(.caption) .foregroundStyle(.secondary) } diff --git a/gitrelay/Views/Detail/RepoHeaderView.swift b/gitrelay/Views/Detail/RepoHeaderView.swift index d2e3f1c..2681a8f 100644 --- a/gitrelay/Views/Detail/RepoHeaderView.swift +++ b/gitrelay/Views/Detail/RepoHeaderView.swift @@ -27,11 +27,11 @@ struct RepoHeaderView: View { targetRow(repo.targets[0]) } } else { - LabeledContent(String.loc("Targets (\(repo.targets.count))")) { + LabeledContent(String(format: String.loc("Targets (%lld)"), repo.targets.count)) { VStack(alignment: .leading, spacing: DesignTokens.Spacing.xxs) { ForEach(Array(repo.targets.enumerated()), id: \.element.id) { index, target in HStack(spacing: DesignTokens.Spacing.xs) { - Text(String.loc("\(index + 1).")) + Text(String(format: String.loc("%@."), String(index + 1))) .foregroundStyle(.secondary) targetRow(target) } diff --git a/gitrelay/Views/Detail/RepoIdleRowView.swift b/gitrelay/Views/Detail/RepoIdleRowView.swift index 45a74b6..8f6af84 100644 --- a/gitrelay/Views/Detail/RepoIdleRowView.swift +++ b/gitrelay/Views/Detail/RepoIdleRowView.swift @@ -13,12 +13,12 @@ struct RepoIdleRowView: View { VStack(alignment: .leading, spacing: DesignTokens.Spacing.xxs) { RepoStatusLabel(status: status) if let lastSyncedAt { - Text(String.loc("Last synced: \(lastSyncedAt.formatted(.dateTime.year().month().day().hour().minute()))")) + Text(String(format: String.loc("Last synced: %@"), lastSyncedAt.formatted(.dateTime.year().month().day().hour().minute()))) .font(.caption) .foregroundStyle(.secondary) } if let lastVerifiedAt { - Text(String.loc("Last verified: \(lastVerifiedAt.formatted(.relative(presentation: .named)))")) + Text(String(format: String.loc("Last verified: %@"), lastVerifiedAt.formatted(.relative(presentation: .named)))) .font(.caption) .foregroundStyle(.secondary) } diff --git a/gitrelay/Views/Detail/RepoStatusLabel.swift b/gitrelay/Views/Detail/RepoStatusLabel.swift index fdc31ff..de96589 100644 --- a/gitrelay/Views/Detail/RepoStatusLabel.swift +++ b/gitrelay/Views/Detail/RepoStatusLabel.swift @@ -15,7 +15,7 @@ struct RepoStatusLabel: View { private var statusText: some View { switch status { case .ahead(let n): - Text(String.loc("src is \(n) commits ahead")) + Text(String(format: String.loc("src is %lld commits ahead"), n)) .foregroundStyle(DesignTokens.StatusColor.ahead) case .idle: Text(String.loc("Synced")) diff --git a/gitrelay/Views/Detail/SyncHistorySparklineView.swift b/gitrelay/Views/Detail/SyncHistorySparklineView.swift index 1fd7d5b..906560f 100644 --- a/gitrelay/Views/Detail/SyncHistorySparklineView.swift +++ b/gitrelay/Views/Detail/SyncHistorySparklineView.swift @@ -73,14 +73,14 @@ struct SyncHistorySparklineView: View { private func dayHelp(for day: SyncHistorySparkline.Day) -> String { let dateText = day.date.formatted(.dateTime.month(.abbreviated).day()) if day.total == 0 { - return String.loc("\(dateText): No Syncs") + return String(format: String.loc("%@: No Syncs"), dateText) } - return String.loc("\(dateText): \(day.successes) succeeded, \(day.failures) failed") + return String(format: String.loc("%@: %lld succeeded, %lld failed"), dateText, day.successes, day.failures) } private var accessibilitySummary: String { let successes = sparkline.days.reduce(0) { $0 + $1.successes } let failures = sparkline.days.reduce(0) { $0 + $1.failures } - return String.loc("Over the last 30 days, \(successes) succeeded and \(failures) failed") + return String(format: String.loc("Over the last 30 days, %lld succeeded and %lld failed"), successes, failures) } } diff --git a/gitrelay/Views/Repositories/RepoPairTableView.swift b/gitrelay/Views/Repositories/RepoPairTableView.swift index 2ee2073..0919bab 100644 --- a/gitrelay/Views/Repositories/RepoPairTableView.swift +++ b/gitrelay/Views/Repositories/RepoPairTableView.swift @@ -23,7 +23,7 @@ struct RepoPairTableView: View { VStack(spacing: 0) { PaneHeaderView( title: MainSidebarItem.repositories.title, - subtitle: String.loc("\(appVM.repos.count) repos") + subtitle: String(format: String.loc("%lld repos"), appVM.repos.count) ) { HStack(spacing: DesignTokens.Spacing.sm) { searchField(text: $appVM.sidebarSearchText) @@ -55,7 +55,7 @@ struct RepoPairTableView: View { Button(String.loc("Cancel"), role: .cancel) { } } message: { id in let name = appVM.repos.first(where: { $0.id == id })?.name ?? "" - Text(String.loc("Delete “\(name)”? The local mirror cache will also be deleted. This action cannot be undone.")) + Text(String(format: String.loc("Delete “%@”? The local mirror cache will also be deleted. This action cannot be undone."), name)) } } @@ -273,7 +273,7 @@ private struct RepoPairPathCell: View { style: .continuous ) ) - .help(String.loc("\(additionalCount) more targets")) + .help(String(format: String.loc("%lld more targets"), additionalCount)) } } .help(fullURL) diff --git a/gitrelay/Views/Settings/ProviderAccountRowView.swift b/gitrelay/Views/Settings/ProviderAccountRowView.swift index 053aed2..2d63b99 100644 --- a/gitrelay/Views/Settings/ProviderAccountRowView.swift +++ b/gitrelay/Views/Settings/ProviderAccountRowView.swift @@ -35,7 +35,7 @@ struct ProviderAccountRowView: View { Button(String.loc("Test"), action: onTest) .disabled(isTesting || !summary.hasToken) - .help(String.loc("Ask \(summary.provider.shortName) whether the saved token still works")) + .help(String(format: String.loc("Ask %@ whether the saved token still works"), summary.provider.shortName)) } } diff --git a/gitrelay/Views/Settings/SettingsView.swift b/gitrelay/Views/Settings/SettingsView.swift index 3924a26..8c5b083 100644 --- a/gitrelay/Views/Settings/SettingsView.swift +++ b/gitrelay/Views/Settings/SettingsView.swift @@ -100,10 +100,6 @@ struct SettingsView: View { @State private var showImportModePicker = false @State private var pendingImportURL: URL? @State private var configMessage: String? - @State private var accountSummaries: [ProviderAccountSummary] = [] - @State private var tokenTestOutcomes: [String: ProviderTokenTestOutcome] = [:] - @State private var accountsUnderTest: Set = [] - @State private var isPresentingAddToken = false var body: some View { VStack(spacing: 0) { @@ -119,15 +115,6 @@ struct SettingsView: View { loginItem.refresh() syncCacheControlsFromStore() appVM.refreshMirrorCacheUsage() - reloadAccounts() - } - .sheet(isPresented: $isPresentingAddToken) { - AddProviderTokenSheet( - onSaved: { provider, label in - reloadAccounts() - testToken(provider: provider, accountLabel: label) - } - ) } .alert( String.loc("Import Configuration"), @@ -181,8 +168,6 @@ struct SettingsView: View { @Bindable var security = securityStore @Bindable var behavior = behaviorStore - accountsSection() - Section { Toggle( String.loc("Open at Login"), @@ -228,72 +213,6 @@ struct SettingsView: View { } } - // MARK: - Accounts (issue #104) - - private var visibleAccounts: [ProviderAccountSummary] { - accountSummaries - } - - @ViewBuilder - private func accountsSection() -> some View { - Section { - if visibleAccounts.isEmpty { - Text(String.loc("No account is connected yet.")) - .foregroundStyle(.secondary) - } else { - ForEach(visibleAccounts) { summary in - ProviderAccountRowView( - summary: summary, - outcome: tokenTestOutcomes[summary.id], - isTesting: accountsUnderTest.contains(summary.id), - onTest: { - testToken(provider: summary.provider, accountLabel: summary.label) - } - ) - } - } - - Button { - isPresentingAddToken = true - } label: { - Label(String.loc("Add Token"), systemImage: "plus") - } - } header: { - Text(String.loc("Accounts")) - } footer: { - Text(String.loc("Tokens are stored in the Keychain and are never written to a log or to exported configuration. Test asks the provider whether a saved token still works.")) - } - } - - private func reloadAccounts() { - accountSummaries = ProviderAccountSummary.listed( - ProviderAccountSummary.summaries( - recordsByProvider: ProviderAccountStore.allAccounts(), - hasToken: { provider, label in - ProviderTokenStore.load(provider: provider, accountLabel: label) != nil - } - ) - ) - } - - private func testToken(provider: GitProvider, accountLabel: String) { - let id = ProviderAccount.id(provider: provider, label: accountLabel) - guard !accountsUnderTest.contains(id) else { return } - accountsUnderTest.insert(id) - tokenTestOutcomes[id] = nil - - Task { - let outcome = await ProviderTokenTester.run( - provider: provider, - accountLabel: accountLabel, - host: ProviderAccountStore.host(for: provider, label: accountLabel) - ) - accountsUnderTest.remove(id) - tokenTestOutcomes[id] = outcome - reloadAccounts() - } - } - private var paneSelection: Binding { Binding( get: { selectedPane }, @@ -315,7 +234,7 @@ struct SettingsView: View { value: $store.preferences.consecutiveFailureThreshold, in: 1...20 ) { - Text(String.loc("Consecutive failure threshold: \(store.preferences.consecutiveFailureThreshold)")) + Text(String(format: String.loc("Consecutive failure threshold: %lld"), store.preferences.consecutiveFailureThreshold)) } .disabled(!store.preferences.notificationsEnabled) @@ -346,7 +265,7 @@ struct SettingsView: View { in: 1...GitRetryPolicy.clampedMaxAttempts(100) ) { Text( - String.loc("Transient network retries: \(store.preferences.transientGitMaxAttempts) attempts") + String(format: String.loc("Transient network retries: %lld attempts"), store.preferences.transientGitMaxAttempts) ) } } header: { @@ -361,7 +280,7 @@ struct SettingsView: View { in: NotificationPreferences.maxConcurrentSyncsRange ) { Text( - String.loc("Max concurrent syncs: \(store.preferences.maxConcurrentSyncs)") + String(format: String.loc("Max concurrent syncs: %lld"), store.preferences.maxConcurrentSyncs) ) } } header: { @@ -416,7 +335,7 @@ struct SettingsView: View { if webhookStore.preferences.listenerEnabled { if let port = appVM.webhookListenPort { LabeledContent(String.loc("Listening Address")) { - Text(String.loc("127.0.0.1:\(port)")) + Text(String(format: String.loc("127.0.0.1:%lld"), port)) .font(.system(.body, design: .monospaced)) .textSelection(.enabled) } @@ -731,9 +650,20 @@ struct SettingsView: View { let data = try Data(contentsOf: url) let plan = try appVM.importConfiguration(from: data, mode: mode) if plan.skippedRepoCount > 0 { - configMessage = String.loc("Imported \(plan.importedRepoCount) repositories (skipped \(plan.skippedRepoCount) existing). Repositories missing credentials are marked and will not sync until you edit them.") + configMessage = String( + format: String.loc( + "Imported %lld repositories (skipped %lld existing). Repositories missing credentials are marked and will not sync until you edit them." + ), + plan.importedRepoCount, + plan.skippedRepoCount + ) } else { - configMessage = String.loc("Imported \(plan.importedRepoCount) repositories. Repositories missing credentials are marked and will not sync until you edit them.") + configMessage = String( + format: String.loc( + "Imported %lld repositories. Repositories missing credentials are marked and will not sync until you edit them." + ), + plan.importedRepoCount + ) } } catch { configMessage = error.localizedDescription @@ -744,7 +674,7 @@ struct SettingsView: View { private func tunnelHint(available: Bool, tool: String, command: String) -> some View { VStack(alignment: .leading, spacing: DesignTokens.Spacing.formFieldGap) { Label( - available ? String.loc("\(tool) detected") : String.loc("\(tool) not detected (you can install it manually)"), + available ? String(format: String.loc("%@ detected"), tool) : String(format: String.loc("%@ not detected (you can install it manually)"), tool), systemImage: available ? "checkmark.circle" : "questionmark.circle" ) .font(.caption) diff --git a/gitrelay/Views/Settings/VerificationSettingsView.swift b/gitrelay/Views/Settings/VerificationSettingsView.swift index 9590de4..2b809c8 100644 --- a/gitrelay/Views/Settings/VerificationSettingsView.swift +++ b/gitrelay/Views/Settings/VerificationSettingsView.swift @@ -13,7 +13,7 @@ struct VerificationSettingsView: View { } Stepper(value: sampleSizeBinding, in: VerificationPreferences.sampleSizeRange) { - Text(String.loc("Sample \(appVM.verificationPreferences.sampleSize) repositories each time")) + Text(String(format: String.loc("Sample %lld repositories each time"), appVM.verificationPreferences.sampleSize)) } if let next = appVM.nextVerificationFireDate() { diff --git a/gitrelay/Views/Sheet/AddEditRepoSheet.swift b/gitrelay/Views/Sheet/AddEditRepoSheet.swift index 035f3ff..4e15428 100644 --- a/gitrelay/Views/Sheet/AddEditRepoSheet.swift +++ b/gitrelay/Views/Sheet/AddEditRepoSheet.swift @@ -374,7 +374,7 @@ struct AddEditRepoSheet: View { Toggle(String.loc("Allow Instant Webhook Sync"), isOn: $vm.webhookEnabled) if vm.webhookEnabled { if let editing = editingRepo { - Text(String.loc("Path: /hook/\(editing.webhookPathID)")) + Text(String(format: String.loc("Path: /hook/%@"), editing.webhookPathID)) .font(.system(.caption, design: .monospaced)) .textSelection(.enabled) @@ -591,9 +591,9 @@ struct AddEditRepoSheet: View { hookURL: hookURL, secret: secret ) - return String.loc("Registered webhook #\(registration.id) on GitHub.") + return String(format: String.loc("Registered webhook #%@ on GitHub."), registration.id) } catch { - return String.loc("Automatic webhook registration failed: \(error.localizedDescription)") + return String(format: String.loc("Automatic webhook registration failed: %@"), error.localizedDescription) } } diff --git a/gitrelay/Views/Sheet/AuthFieldView.swift b/gitrelay/Views/Sheet/AuthFieldView.swift index 9e4c710..8698c4a 100644 --- a/gitrelay/Views/Sheet/AuthFieldView.swift +++ b/gitrelay/Views/Sheet/AuthFieldView.swift @@ -19,7 +19,7 @@ struct AuthFieldView: View { @State private var previewError: String? private var resolvedPickerTitle: String { - pickerTitle ?? String.loc("\(label) Authentication") + pickerTitle ?? String(format: String.loc("%@ Authentication"), label) } var body: some View { diff --git a/gitrelay/Views/Sheet/DestructivePushConfirmationSheet.swift b/gitrelay/Views/Sheet/DestructivePushConfirmationSheet.swift index 77e915a..0e73139 100644 --- a/gitrelay/Views/Sheet/DestructivePushConfirmationSheet.swift +++ b/gitrelay/Views/Sheet/DestructivePushConfirmationSheet.swift @@ -40,7 +40,7 @@ struct DestructivePushConfirmationSheet: View { VStack(alignment: .leading, spacing: DesignTokens.Spacing.lg) { if !plan.deletedRefs.isEmpty { refSection( - title: String.loc("Delete \(plan.deletedRefs.count) refs"), + title: String(format: String.loc("Delete %lld refs"), plan.deletedRefs.count), refs: plan.deletedRefs, symbol: "trash", tint: DesignTokens.StatusColor.error, @@ -49,7 +49,7 @@ struct DestructivePushConfirmationSheet: View { } if !plan.forcedUpdateRefs.isEmpty { refSection( - title: String.loc("Force-update \(plan.forcedUpdateRefs.count) refs"), + title: String(format: String.loc("Force-update %lld refs"), plan.forcedUpdateRefs.count), refs: plan.forcedUpdateRefs, symbol: "arrow.triangle.2.circlepath", tint: DesignTokens.StatusColor.warning, diff --git a/gitrelay/Views/Sheet/EditTagGroupFrequencySheet.swift b/gitrelay/Views/Sheet/EditTagGroupFrequencySheet.swift index daaf31c..d2b8bed 100644 --- a/gitrelay/Views/Sheet/EditTagGroupFrequencySheet.swift +++ b/gitrelay/Views/Sheet/EditTagGroupFrequencySheet.swift @@ -26,7 +26,7 @@ struct EditTagGroupFrequencySheet: View { Form { Section { - Text(String.loc("Set the sync frequency for all \(repoCount) repositories in “\(groupTitle)” to:")) + Text(String(format: String.loc("Set the sync frequency for all %lld repositories in “%@” to:"), repoCount, groupTitle)) .font(.callout) .foregroundStyle(.secondary) FrequencyPickerView(frequency: $frequency) diff --git a/gitrelay/Views/Sheet/GenerateSSHKeySheet.swift b/gitrelay/Views/Sheet/GenerateSSHKeySheet.swift index 0e16615..7e9daa9 100644 --- a/gitrelay/Views/Sheet/GenerateSSHKeySheet.swift +++ b/gitrelay/Views/Sheet/GenerateSSHKeySheet.swift @@ -34,7 +34,7 @@ struct GenerateSSHKeySheet: View { Section { TextField(String.loc("Private Key Path"), text: $keyPath) .font(.system(.body, design: .monospaced)) - Text(String.loc("The default path is \(SSHKeyGenerator.defaultDisplayPath). The public key will be written to a .pub file at the same path.")) + Text(String(format: String.loc("The default path is %@. The public key will be written to a .pub file at the same path."), SSHKeyGenerator.defaultDisplayPath)) .font(.caption) .foregroundStyle(.secondary) } header: { diff --git a/gitrelay/Views/Sheet/SSHKeyGeneratedSheet.swift b/gitrelay/Views/Sheet/SSHKeyGeneratedSheet.swift index 36decf6..74d3bcb 100644 --- a/gitrelay/Views/Sheet/SSHKeyGeneratedSheet.swift +++ b/gitrelay/Views/Sheet/SSHKeyGeneratedSheet.swift @@ -31,7 +31,7 @@ struct SSHKeyGeneratedSheet: View { VStack(alignment: .leading, spacing: DesignTokens.Spacing.formFieldGap) { Text(String.loc("SSH Key Generated")) .font(.headline) - Text(String.loc("Add the public key to \(GitRemoteHost.sshKeysSettingsLabel(for: provider)) to use SSH authentication.")) + Text(String(format: String.loc("Add the public key to %@ to use SSH authentication."), GitRemoteHost.sshKeysSettingsLabel(for: provider))) .font(.callout) .foregroundStyle(.secondary) if didCopy { @@ -65,7 +65,7 @@ struct SSHKeyGeneratedSheet: View { ClipboardService.copy(result.publicKey) didCopy = true } - Button(String.loc("Open \(GitRemoteHost.sshKeysSettingsLabel(for: provider)) SSH Settings")) { + Button(String(format: String.loc("Open %@ SSH Settings"), GitRemoteHost.sshKeysSettingsLabel(for: provider))) { if !didCopy { ClipboardService.copy(result.publicKey) didCopy = true diff --git a/gitrelay/Views/Sidebar/FailureCountBadge.swift b/gitrelay/Views/Sidebar/FailureCountBadge.swift index 8b84596..0a65260 100644 --- a/gitrelay/Views/Sidebar/FailureCountBadge.swift +++ b/gitrelay/Views/Sidebar/FailureCountBadge.swift @@ -4,7 +4,7 @@ struct FailureCountBadge: View { let count: Int var body: some View { - Text(String.loc("× \(count)")) + Text(String(format: String.loc("× %lld"), count)) .font(.caption2.weight(.semibold)) .monospacedDigit() .lineLimit(1) @@ -13,7 +13,7 @@ struct FailureCountBadge: View { .padding(.horizontal, DesignTokens.Spacing.xs) .frame(minWidth: 28, minHeight: 18) .background(DesignTokens.Surface.badgeFill, in: .capsule) - .help(String.loc("\(count) consecutive failures")) - .accessibilityLabel(String.loc("\(count) consecutive failures")) + .help(String(format: String.loc("%lld consecutive failures"), count)) + .accessibilityLabel(String(format: String.loc("%lld consecutive failures"), count)) } } diff --git a/gitrelay/Views/Sidebar/RepoRowCaptionView.swift b/gitrelay/Views/Sidebar/RepoRowCaptionView.swift index ab8f88e..68b3cec 100644 --- a/gitrelay/Views/Sidebar/RepoRowCaptionView.swift +++ b/gitrelay/Views/Sidebar/RepoRowCaptionView.swift @@ -20,7 +20,7 @@ struct RepoRowCaptionView: View { return String.loc("Not Synced") case .lastSync(let date): let relative = date.formatted(.relative(presentation: .named)) - return String.loc("Last synced \(relative)") + return String(format: String.loc("Last synced %@"), relative) case .queued: return String.loc("Queued") case .syncing(let text): diff --git a/gitrelayTests/AppLanguageStoreTests.swift b/gitrelayTests/AppLanguageStoreTests.swift index 67e2fe8..c0f384b 100644 --- a/gitrelayTests/AppLanguageStoreTests.swift +++ b/gitrelayTests/AppLanguageStoreTests.swift @@ -127,6 +127,12 @@ struct AppLocalizationTests { #expect(AppLocalization.localizationName(for: "en", available: available) == "en") } + @Test func locCallableFromNonisolatedContext() { + defer { AppLocalization.resetOverride() } + AppLocalization.apply(.english) + #expect(NonisolatedLocProbe.settingsLabel == "Settings") + } + @Test @MainActor func stringFollowsPreferenceChanges() { defer { AppLocalization.resetOverride() } let suiteName = "gitrelay.tests.localization.\(UUID().uuidString)" @@ -146,3 +152,7 @@ struct AppLocalizationTests { #expect(chinese == "设置") } } + +private nonisolated enum NonisolatedLocProbe { + static var settingsLabel: String { String.loc("Settings") } +} diff --git a/scripts/check_string_catalog.py b/scripts/check_string_catalog.py index e77ea09..aac6a82 100755 --- a/scripts/check_string_catalog.py +++ b/scripts/check_string_catalog.py @@ -11,6 +11,7 @@ CATALOGS = ( ROOT / "gitrelay" / "Localizable.xcstrings", ROOT / "gitrelayWidget" / "Localizable.xcstrings", + ROOT / "gitrelay" / "InfoPlist.xcstrings", ) REQUIRED_LOCALES = ("en", "zh-Hans") @@ -133,12 +134,27 @@ ), # Quiet widget face (issue #89): 今日 + three counts, no glyph-leading keys. ROOT / "gitrelayWidget" / "Localizable.xcstrings": ( + "All mirrors look healthy", + "Not Synced", "Queued", + "Stale", + "Sync Health", + "Syncing", "Today", + "Today's mirror sync status at a glance.", "Today: %lld succeeded, %lld failed, %lld not run", ), + ROOT / "gitrelay" / "InfoPlist.xcstrings": ( + "NSFaceIDUsageDescription", + "NSFocusStatusUsageDescription", + ), } +# Widget catalog must stay widget-only; GitRelayCore in the extension must not +# auto-extract the app catalog (SWIFT_EMIT_LOC_STRINGS=NO on gitrelayWidget). +WIDGET_CATALOG = ROOT / "gitrelayWidget" / "Localizable.xcstrings" +WIDGET_MAX_KEYS = 9 + # The widget target builds with STRING_CATALOG_GENERATE_SYMBOLS=YES, so every # widget catalog key has to derive a Swift identifier. Keys such as "✓ %lld" # do not, and break the Xcode 26 build. @@ -193,6 +209,13 @@ def validate_catalog(catalog: Path) -> list[str]: f"{catalog.relative_to(ROOT)} {key!r}: key must start with an ASCII " "letter so STRING_CATALOG_GENERATE_SYMBOLS can derive a Swift symbol" ) + + if catalog == WIDGET_CATALOG and len(strings) > WIDGET_MAX_KEYS: + errors.append( + f"{catalog.relative_to(ROOT)}: expected at most {WIDGET_MAX_KEYS} keys " + f"(widget-only strings), found {len(strings)}" + ) + return errors