Skip to content

Adopt Xcode String Catalog symbol generation across the app#79

Closed
kyleve wants to merge 9 commits into
mainfrom
cursor/string-catalog-codegen
Closed

Adopt Xcode String Catalog symbol generation across the app#79
kyleve wants to merge 9 commits into
mainfrom
cursor/string-catalog-codegen

Conversation

@kyleve

@kyleve kyleve commented Jul 13, 2026

Copy link
Copy Markdown
Owner

What

Switches the whole repo onto Xcode's built-in String Catalog symbol generation and makes every string reference type-safe. The hand-maintained string facades (Strings.swift ~1,730 lines, ShareStrings, WidgetStrings) and all raw String(localized: "key", bundle: .module) lookups are gone; user-facing copy now resolves through generated LocalizedStringResource symbols, so a typo'd or removed key is a compile error instead of a runtime surprise.

Scope is the codegen migration only — no new localization of currently-hardcoded/DEBUG surfaces or Info.plist strings.

How it works

  • STRING_CATALOG_GENERATE_SYMBOLS = YES is set project-wide in Project.swift for the Tuist-native app/extension targets. The SwiftPM library targets generate symbols automatically from the toolchain (verified empirically), so they need no wiring.
  • A manual catalog key some.key generates a .someKey symbol on LocalizedStringResource, referenced directly at call sites: Text(.tabPrimary), String(localized: .commonOk), Label(.evidenceAdd, systemImage:).
  • Generated symbols are internal per target — fine here, because cross-module reuse already flows through public Swift API (e.g. the share extension renders kinds via WhereUI's public EvidenceKind.displayName), never raw keys.
  • Requires Xcode 27. Symbol generation for SwiftPM package targets is only reliable on Xcode 27 (Swift 6.4); Xcode 26.x mis-ordered generation in the test-bundle build graph, so CI now runs on GitHub's xcode-27 runner image (both jobs), destination iPhone 17 / iOS 27.0. The deployment target stays iOS 26.0 — building against the iOS 27 SDK is fine; raising the minimum OS is a separate follow-up.

Commits (one per module, bisectable)

  1. Groundwork — enable the build setting; normalize the extension catalogs so every key is manual.
  2. RegionKitRegion.localizedName switches over .regionCalifornia etc.
  3. LifecycleKitLifecycleFailureView uses .failureLaunchTitle / .failureLaunchRetry.
  4. WhereCore — reminder/summary/issue-alert/backup strings; two fragile String(format:) compositions replaced with type-safe symbols (output unchanged).
  5. WhereUI — delete the Strings facade; rewrite ~277 call sites across 30 files. Add WhereFormat for the cases that genuinely need logic (grouping-free years, plurals, enum switches, composed accessibility labels), each composing generated symbols internally. Add the 34 Evidence/common keys that previously only existed via defaultValue:. Replace StringsTests with WhereFormatTests.
  6. WhereShareExtension / WhereWidgets — drop ShareStrings / WidgetStrings for the extensions' own generated symbols.
  7. Docs — update the localization guidance (feature + module AGENTS/READMEs) to the "add a manual key → reference its symbol" workflow.

Verification

  • ./swiftformat --lint . clean (326 files).
  • Full Stuff-iOS-Tests scheme green (all bundles) under Xcode 27; CI runs it on the xcode-27 image against iPhone 17 / iOS 27.0.
  • Catalog re-serialization verified lossless: 0 keys dropped, 0 existing values changed, 34 added.
  • All composed/interpolated catalog values confirmed to match the old defaultValue: separators and argument order, and no catalog value contains markdown, so Text(String)Text(.symbol) renders identically.

Reviewer notes

  • The WhereUI catalog diff is large because the file was re-serialized into one fully-sorted, all-manual catalog — it's a re-sort + extractionState normalization, not content edits.
  • Outside Text/Label/Button (verified to accept LocalizedStringResource), call sites use String(localized: .symbol) as a deliberate safety choice; a follow-up could tighten .navigationTitle(...) etc. to bare symbols.
  • WhereFormat's enum-switch/compose helpers were cross-checked by hand against the old mappings; the untested cases are at parity with the previous StringsTests coverage.
Open in Web Open in Cursor 

kyleve added 8 commits July 12, 2026 17:51
Turn on Xcode's type-safe String Catalog codegen across the six catalog-backed
targets, the first step of the codegen migration.

- Project.swift: set STRING_CATALOG_GENERATE_SYMBOLS=YES in the project base
  settings so the Tuist-native app/extension targets generate LocalizedStringResource
  symbols. The SwiftPM package targets (WhereUI, WhereCore, RegionKit, LifecycleKit)
  generate symbols automatically from the toolchain and need no setting.
- Normalize the two Tuist-native catalogs so every key produces a symbol:
  WhereWidgets keys stale -> manual; WhereShareExtension keys gain
  extractionState: manual.

Verified by building RegionKit (SPM) and WhereWidgets (Tuist-native) against a
throwaway generated-symbol reference; both compiled. No behavior change.
Region.localizedName now switches over the generated .region<Case> symbols
(String(localized: .regionCalifornia)) instead of raw "region.<rawValue>" keys
with bundle: .module. The exhaustive switch still makes a new region case a
compile error until its manual catalog key (and thus symbol) exists; update the
README's add-a-region note accordingly.
Replace the String(localized:bundle:.module) title/retry lookups with the
generated .failureLaunchTitle / .failureLaunchRetry symbols passed straight to
Label and Button.
Replace every String(localized: "key", bundle: .module) in the reminder,
daily-summary, issue-alert, and backup paths with the generated
LocalizedStringResource symbols. Plural/interpolated keys become generated
functions (.dataIssuesNotificationBody(count), .summaryNotificationDayCount(days),
.backupErrorUnsupportedFormatVersion(version)).

DailySummaryReconciler.summaryFragment and BackupError.errorDescription drop
their String(format:) composition in favor of the type-safe symbols; the
composed summary body ("3 days in California, 1 day in New York") is unchanged,
verified by WhereCoreTests.
Delete the ~1730-line hand-written Strings enum and reference Xcode's generated
LocalizedStringResource symbols directly at call sites (Text(.tabPrimary),
String(localized: .commonOk), etc.). ~277 call sites across 30 view files.

- Add WhereFormat: the small set of helpers that genuinely need logic — the
  non-catalog formatters (grouping-free year, drift measurement, coordinate),
  count/plural helpers, enum switches (region-map kind, resolution section,
  recent-activity window/reason), and the composed accessibility labels. Each
  composes generated symbols with runtime values, so a removed key is a compile
  error.
- EvidenceKind.displayName now switches over the generated evidence-kind symbols.
- Catalog: mark the 46 auto-extracted keys manual and add the 34 Evidence/common
  keys that only existed via defaultValue: so every referenced key now generates
  a symbol; the file is re-sorted with all keys manual.
- Replace StringsTests with WhereFormatTests: compile-time safety covers key
  existence, so the suite now pins catalog values, plural variations, year
  formatting, EvidenceKind names, and the accessibility-cue composition.

All 198 WhereUITests pass; Where app builds.
Replace the ShareStrings facade with the extension's own generated
LocalizedStringResource symbols in ShareEvidenceView (bare symbols in
Text/Button, String(localized:) elsewhere; the attachment header picks its
plural symbol inline). Delete ShareStrings.swift.
Replace the WidgetStrings facade with the extension's generated symbols for the
widget-gallery name/description on each StaticConfiguration
(String(localized: .widgetGalleryTodayName), etc.). Delete WidgetStrings.swift.
Update the localization guidance to the generated-symbol model: add manual keys
to the catalog and reference the generated LocalizedStringResource symbols
directly (Text(.tabPrimary), String(localized: .commonOk)), with WhereUI's
composition/formatting living in WhereFormat. Drops the Strings.swift /
ShareStrings / WidgetStrings / bundle: .module references from the Where feature
AGENTS.md and the WhereCore / WhereWidgets / WhereShareExtension module docs.
@kyleve
kyleve marked this pull request as draft July 13, 2026 01:24
@kyleve

kyleve commented Jul 13, 2026

Copy link
Copy Markdown
Owner Author

Posted by an AI agent on @kyleve's behalf.

CI is red due to an Xcode-toolchain gap, not a defect in this change — recommend holding until the runners have Xcode 27. Marking as draft.

Root cause

Xcode's built-in String Catalog symbol generation for SwiftPM package targets is unreliable on Xcode 26.x. Our RegionKit / LifecycleKit / WhereCore / WhereUI symbols generate fine in the app build graph, but the generated symbols file isn't reliably produced before the target's own sources compile in the test-bundle build graph — so CI fails with:

type 'String.LocalizationValue' has no member 'regionCalifornia'

Reproduced locally under Xcode 26.6 (Swift 6.3.3, = the macos-26 runner). Builds and passes the full Stuff-iOS-Tests scheme cleanly under Xcode 27 (Swift 6.4), where the SwiftPM change that wires catalogs into the Sources phase makes generation deterministic. GitHub's macos-26 image only ships Xcode 26.x — there is no Xcode 27 to pin to yet.

Fixes explored — all dead ends on 26.x

  • Force the setting on the package targetsSTRING_CATALOG_GENERATE_SYMBOLS is ignored for SwiftPM package targets (confirmed: =NO disabled a Tuist-native target but not a package target; CLI + XCODE_XCCONFIG_FILE overrides don't reach them). Can neither reliably enable nor disable it.
  • Tuist XcodeProj integration (.external(name:)) — blocked by our single root-package layout: tuist generate crashes with a duplicate-path error, the root package's products aren't exposed as .external, and SwiftPM won't let a subdirectory manifest reference sources via ../ (would require relocating all module sources).
  • Commit a generated-symbols shim (the clever idea we tried) — Xcode 26.x's built-in generation for packages is unconditional, so a committed shim collides → invalid redeclaration. This duplicate-symbols wall is documented independently: https://elegantchaos.com/2026/02/12/string-catalogues.html

Corroboration that this area is buggy / actively patched on 26.x

Recommendation

The branch is correct and needs zero changes — it's green on Xcode 27. Hold as draft until the CI runners have Xcode 27, at which point this should pass as-is. If we need type-safe strings sooner, the only 26.x-viable path is a custom SwiftPM build-tool-plugin codegen (gated to not run under Xcode) — more moving parts; happy to spike it separately if desired.

Xcode's String Catalog symbol generation for SwiftPM package targets is
only reliable on Xcode 27 (Swift 6.4); the macos-26 image (Xcode 26.x)
produced spurious 'no member' errors in the test-bundle build graph (see
PR #79 discussion). GitHub now offers the xcode-27 image in public
preview, so switch both CI jobs to runs-on: xcode-27.

The image ships only the iOS 27.0 simulator runtime, so pin the test
destination to iPhone 17 / OS 27.0. Deployment target stays iOS 26.0 —
building it with the iOS 27 SDK is fine; raising the min OS is a separate
follow-up. Add a CoreSimulator warm-up step since the preview image can
need a nudge to rebuild its device cache. Update docs (AGENTS.md,
README.md, RegionViewer) and the profile script default to match.
@kyleve
kyleve marked this pull request as ready for review July 23, 2026 02:33
@kyleve

kyleve commented Jul 23, 2026

Copy link
Copy Markdown
Owner Author

Posted by an AI agent on @kyleve's behalf.

Unblocked: GitHub now ships the xcode-27 runner image in public preview (changelog), so the toolchain gap called out above is resolved.

Commit c1068c6 moves both CI jobs to runs-on: xcode-27 (the new Xcode-versioned label — GitHub's new support model keys images to the major Xcode version rather than the OS). No source changes to this branch — per the earlier analysis, it's green on Xcode 27 as-is.

Details:

  • The xcode-27 image ships only the iOS 27.0 simulator runtime (Xcode 27 SDK, arm64), so the test destination is pinned to iPhone 17 / OS 27.0.
  • Deployment target stays iOS 26.0 — building it against the iOS 27 SDK is fine; raising the minimum OS is a separate follow-up PR.
  • Added a xcrun simctl list CoreSimulator warm-up step, since the preview image can need a nudge to rebuild its device cache before a destination resolves.
  • Docs (AGENTS.md, README.md, RegionViewer/README.md) and the profile script default updated to match.

Marking ready for review; CI is now running on the new image.

@kyleve

kyleve commented Jul 23, 2026

Copy link
Copy Markdown
Owner Author

Posted by an AI agent on @kyleve's behalf.

Closing this in favor of a fresh redo. The branch had fallen 108 commits behind main (9 days); a test-merge produced 26 conflicts, including modify/delete conflicts on the central Strings.swift (deleted here, still edited on main) and on view files main has since moved/reorganized (Secondary/Elsewhere/, plus new Evidence/, Onboarding/, Developer/, LaunchSplash/ views that never got converted here). Resolving those would amount to re-doing the call-site conversion in a broken half-merged tree against a re-serialized catalog — strictly worse than re-applying the migration cleanly onto current main.

Superseded by:

@kyleve kyleve closed this Jul 23, 2026
kyleve added a commit that referenced this pull request Jul 23, 2026
## What

Moves CI onto GitHub's new **`xcode-27`** runner image (public preview) so we build and test on **Xcode 27 (Swift 6.4)**.

## Why

GitHub's [Xcode 27 runner image](https://github.blog/changelog/2026-07-16-xcode-27-runner-image-now-in-public-preview/) is now available, and its new support model keys images to the **Xcode major version** rather than the OS — hence the `xcode-27` label instead of `macos-27`.

Beyond keeping the toolchain current, this **unblocks Xcode's String Catalog symbol generation for SwiftPM package targets**, which is only reliable on Xcode 27 (it mis-ordered generation in the test-bundle build graph on Xcode 26.x). The type-safe strings migration lands as a follow-up PR on top of this.

## Changes

- Both CI jobs (`format`, `test`) → `runs-on: xcode-27`.
- The image ships **only the iOS 27.0 simulator runtime**, so the test destination is pinned to `iPhone 17 / OS 27.0` (the device still exists there).
- Added a `xcrun simctl list` **CoreSimulator warm-up** step — the preview image can need a nudge to rebuild its device cache before a destination resolves.
- Docs + scripts synced: `AGENTS.md`, `README.md`, `RegionViewer/README.md`, and the `profile` / `flaky` script defaults.

## Not in scope

- **Deployment target stays iOS 26.0.** Building against the iOS 27 SDK is fine; raising the minimum OS (for `HistoryObserver`) is a separate follow-up.
- The String Catalog symbol-generation migration (a fresh redo of #79) follows in its own PR.

## Verification

- `./swiftformat --lint` clean.
- Build & test validated by this PR's CI run on the `xcode-27` image.
kyleve added a commit that referenced this pull request Jul 23, 2026
Turn on Xcode's type-safe String Catalog codegen so catalog-backed targets
generate LocalizedStringResource symbols — the first step of redoing the
symbol-generation migration (supersedes #79) on current main.

- Project.swift: set STRING_CATALOG_GENERATE_SYMBOLS=YES in the project base
  settings so the Tuist-native app/extension targets (Where, WhereWidgets,
  WhereShareExtension) generate symbols. The SwiftPM package targets
  (WhereUI, WhereCore, RegionKit, LifecycleKit) generate symbols
  automatically from the toolchain and need no setting.

No behavior change; existing String(localized:) call sites are untouched.
Per-module catalog normalization + call-site conversion follow.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant