diff --git a/docs/jit-bundle-resolution.md b/docs/jit-bundle-resolution.md new file mode 100644 index 00000000..d0a870b3 --- /dev/null +++ b/docs/jit-bundle-resolution.md @@ -0,0 +1,179 @@ +# JIT Bundle Resolution: user code finds the framework it was compiled from + +Status: DRAFT for review — 2026-07-21 + +The JIT recompiles a target's sources into the agent process instead of +loading the target's built binary, so a class declared in target code lives +in the agent image, not in the framework Xcode built. `Bundle(for:)` on such +a class therefore resolves to the agent's bundle — which holds none of the +target's resources — while the real framework wrapper sits fully populated +on disk (compiled string catalogs, plists, Core Data models, asset +catalogs). One narrow carve-out exists today: generated +`Generated*Symbols.swift` files are text-rewritten to point at the wrapper +(`applyResourceBundleRewrites`, #151), which is why a generated asset color +renders while a hand-written `Bundle(for:)` lookup two lines away misses. +The fix is one rule: **a bundle lookup made by JIT-compiled code resolves to +the on-disk wrapper of the target it was compiled from, without the user's +source changing.** + +## Matrix rows this retires + +| Row | Remaining gap | Shared root cause | +|---|---|---| +| R03 | `resource.title` raw, plist miss, momd miss on macOS and iOS while the generated color renders | `Bundle(for: )` resolves to the agent image because the class is compiled into the JIT module (`Compiler.swift:146-176,199-272`); the rewrite that saves the color is filtered to `Generated*Symbols.swift` with an exact-needle match (`XcodeBuildSystem.swift:666-682`), so user sources never get it | + +## What re-verification already closed (2026-07-21, this branch) + +The resource-staging cluster began as three Reproduced rows. Two fell to +fixture-mechanism defects, not product gaps — each proven with a native +control before touching the product, and each fixed in the fixture with +distinguishable failure states: + +- **R02** (SwiftPM localization): `String(localized:bundle:locale:)` never + selects an `.lproj` — the `locale:` parameter only affects interpolation + formatting. The staged bundle was healthy all along; the corrected + fixture resolves through the locale's `.lproj` sub-bundle and every + surface renders Spanish (this branch; R02's row and Fixture Corrections + carry the details). +- **B04** (XCFramework internal resource): `Bundle.allFrameworks` only + lists frameworks containing ObjC classes, and DynamicBadge was pure C — + the fixture could not observe the framework under any product behavior; + its JSON also sat under `Resources/` in a flat iOS framework. With an + ObjC marker class and a root-level resource, the EPC-dlopened framework + resolves via `Bundle(for:)` and serves the payload (this branch; B04's + row and Fixture Corrections carry the details). + This also establishes the load path is sound: a **real** dynamic + framework loaded from the build directory keeps its wrapper identity in + the agent. +- **R03's iOS crash** was a separate defect — the fat-build x86_64 capture + — fixed on main (#438). + +The lesson the family keeps: verify a row's assertion mechanism natively +(outside the daemon) before reading it as a product gap. + +## Today's shape (evidence) + +- **Target code is recompiled, never loaded.** The preview file compiles as + the overlay and the target's remaining sources as the stable module, both + to fresh objects the JIT materializes into the agent + (`Compiler.swift:146-176,199-272`); "the target's own framework is the + Tier 2 recompile itself, never loaded" + (`XcodeBuildSystem.swift:255-256`). A class compiled this way has no dyld + image inside the framework wrapper, so Foundation resolves + `Bundle(for:)` to the agent bundle. +- **The resources exist.** Both platforms' built products contain + `Assets.car`, `en.lproj/Localizable.strings` and + `es.lproj/Localizable.strings` (compiled from the string catalog), + `FixtureInfo.plist`, and `FixtureModel.momd` inside + `XcodeResources.framework` — verified on disk for Debug and + Debug-iphonesimulator. The misses are lookup misses, not staging misses. +- **The carve-out that proves the rule.** `applyResourceBundleRewrites` + (`XcodeBuildSystem.swift:631-656`) rewrites sources whose name matches + `Generated*Symbols.swift` and whose body contains the generator's exact + `ResourceBundleClass` preamble (`:666-682`), substituting + `Bundle(path: )` (`:685-693`). The wrapper path + already rides build settings on both platforms and encodes the + macOS-versioned vs iOS-flat layout difference. User code fails every + filter by construction. +- **Wrapper layout differs per platform.** macOS: versioned bundle, + `CODESIGNING_FOLDER_PATH` ends in `.framework/Versions/A`. iOS: flat + bundle. Any fix must use the setting verbatim rather than assume a + layout. + +## Design: an agent-side `bundleForClass:` fallback + +Rewriting arbitrary user sources would generalize the carve-out but is +brittle text surgery on code we do not control (arbitrary token names, +arbitrary lookup spellings — `Bundle(for:)`, `Bundle(identifier:)`, +`.main`). The durable seam is where resolution happens: Foundation's +`+[NSBundle bundleForClass:]` in the agent process. + +Rule: when the daemon knows the target's wrapper path, the agent installs a +`bundleForClass:` hook. The hook calls the original; if the original +resolved to the agent's own bundle **and** the class carries no image +identity, it returns the wrapper bundle instead. Classes from real images — +the agent's own, dlopen'd dependency frameworks (B04's case), system +frameworks — hit the original path unchanged. + +The discriminator is `class_getImageName(cls) == NULL`, **not** `dladdr`. +Adversarial review (2026-07-21, native experiments) showed `dladdr` on a +class pointer is placement-based nearest-symbol lookup: in a Swift process +it attributes runtime-allocated class metadata — and even real system +classes — to `libswiftCore`'s allocation pool, so it cannot discriminate. +`class_getImageName` stayed NULL for imageless classes and correct for +every real class (pure Swift included). The same review verified the +metaclass swizzle fires for Swift's `Bundle(for:)` on the Xcode 26.2 SDK, +that Foundation's bundle-for-class cache sits below the swizzle (a +late-installed hook is not bypassed by earlier lookups), and that +`Bundle(path:)` on `CODESIGNING_FOLDER_PATH` serves resources for both the +versioned and flat layouts. One measurement remains before the predicate is +final: what `class_getImageName` returns for a **real ORC-materialized** +class in the agent (the review's proxy used `objc_allocateClassPair`). +Stage 1 opens with that diagnostic; if ORC stamps JIT classes with the +agent's own executable path, the predicate degrades to +`original == Bundle.main`, which accepts redirecting agent-image lookups +as the documented cost. + +- **Plumbing:** `BuildContext` gains the optional wrapper path (Xcode + targets: `CODESIGNING_FOLDER_PATH`; SPM/Bazel: nil — SwiftPM's generated + `Bundle.module` accessor already finds the built bundle beside the + products, proven by R02). The session passes it to the agent with the + render request, the same route the crash-notice and setup sidecars ride. +- **Scope:** one target per session, so one wrapper per agent process at a + time; the hook re-arms per session start. +- **What it fixes:** `Bundle(for:)` on any class in JIT-compiled target + code — which also fixes `String(localized:bundle:)` and Core Data + `momd` lookups made against that bundle (R03's three misses). +- **What it deliberately does not touch:** `Bundle.main` (the agent's own + identity, used by the JIT runtime), `Bundle.module` in SPM targets + (already correct), lookups from real dylib images. +- **Known limitation (documented, gated):** Xcode-managed SwiftPM package + products are JIT-linked as archives (`swiftPMPackageProducts`, + `XcodeBuildSystem.swift:907-963`), so a package's classes are imageless + too — the hook would misdirect a package-code `Bundle(for:)` to the + *target's* wrapper. No current matrix row exercises an Xcode target + embedding a resource-bearing package; that row must exist before the + combination ships. The hook installs only when a wrapper is configured + (the Xcode path), so pure-SPM sessions (R02) are inert by construction — + stage 1's manual pass re-runs R02 with the hook code present to prove + it. + +## Implementation stages + +Stages follow the family discipline: design → adversarial review → gates +(/simplify, /code-review, unit tier, integration tier) → manual matrix +re-verification flipping rows in VERIFICATION.md. + +1. **Wrapper plumbing + agent hook.** Opens with the provenance + diagnostic: dump `class_getImageName` and `Bundle(for:)` identity for a + real ORC-materialized class in the macOS agent, deciding the predicate + (see Design). Then `BuildContext.resourceWrapperPath`, the + render-request sidecar, and the `bundleForClass:` hook behind it + (macOS agent and iOS agent app). Unit rows pin the hook's decision + table: imageless class + wrapper → wrapper bundle; real-image class → + original; no wrapper configured → original. Manual: **R03 flips** — + title `Xcode resources loaded`, plist loaded, Core Data model loaded, + color still renders, macOS and iOS; R02 re-run with the hook code + present (must stay inert and render Spanish); B02/B03/B04 and X01/X02 + guards hold (dependency-framework classes must keep resolving to their + own wrappers). iOS must-verifies from review: the swizzle fires in the + agent-app process, `Bundle(path:)` on the host wrapper path resolves + in-sim, and a JIT class's original lookup lands on the agent app's + `Bundle.main`. +2. **Retire the text rewrite.** With the hook in place the + `Generated*Symbols.swift` rewrite is redundant on the Xcode path — + remove `applyResourceBundleRewrites` and its rewrite directory, keep + the tests that pin the generated color rendering. Only after stage 1's + flake record is clean; the rewrite is proven and the hook must earn + the same trust first. + +## Out of scope + +- R01's render half (the `LC_LINKER_OPTION` autolink scan → `addDylib`): + named future work, unchanged by this family. +- Bazel target resource bundles: no matrix row exercises them; add a row + before designing. +- Xcode-style asset-symbol generation for SPM/Bazel targets: different + feature, different family. +- Pinning the Xcode build to one arch (`ARCHS=`): named future + work from #438; read-side capture already tolerates fat builds. diff --git a/examples/regress/VERIFICATION.md b/examples/regress/VERIFICATION.md index f9a84252..40873558 100644 --- a/examples/regress/VERIFICATION.md +++ b/examples/regress/VERIFICATION.md @@ -7,7 +7,8 @@ D09, W01's edit variant) re-verified 2026-07-15 after compile-command capture landed (stage 2). Xcode rows (X01, X02, D08, R03, D01/D07 guards) re-verified 2026-07-15 after build-log capture landed (stage 3). Bazel and binary-framework rows (B01–B04, D04/D05 guards) re-verified 2026-07-15 -after aquery capture landed (stage 4). +after aquery capture landed (stage 4). B04, R02, and R03 re-verified +2026-07-21 ahead of the resource-staging family work. Environment: Xcode 26.2, an iOS 26.3 `previewsmcp-test` iPhone simulator, and the Bazel-built CLI from this checkout. Commands used an isolated daemon @@ -47,11 +48,11 @@ not a nonzero command exit. | B01 | Guard passes | 2026-07-15 (Bazel aquery capture): rendered `Canonical Bzlmod repository` and `Bazel generated source`. The SwiftCompile action's arguments carry the generated source at its execroot path and the canonical external repo's module search path; external dependency archives are force-built for the JIT link. | | B02 | Guard passes | 2026-07-15 (compile capture): rendered both `Static simulator XCFramework` and `Dynamic simulator XCFramework` on iOS — the captured flags resolve the StaticBadge module and the copied static archive in binPath links alongside the dynamic framework. | | B03 | Guard passes | 2026-07-15 (compile capture): rendered `Static simulator XCFramework` on iOS; the static XCFramework's module resolves from the captured flags and its copied `libStaticBadge.a` links from binPath. | -| B04 | Reproduced | The dynamic-only package passed natively and PreviewsMCP loaded it on iOS: the snapshot rendered `Dynamic simulator XCFramework`. The same snapshot reported `framework resource missing`, so the framework's internal JSON was not staged with the loaded binary. | +| B04 | Guard passes | 2026-07-21: with the fixture's lookup corrected (see Fixture Corrections), the iOS snapshot renders both the framework message and the internal JSON payload — the EPC-dlopened framework resolves via `Bundle(for:)` and serves its root-level resource, so nothing was ever missing from staging. The recorded `framework resource missing` was the fixture's own mechanism twice over: `Bundle.allFrameworks` only lists frameworks containing at least one ObjC class (DynamicBadge was pure C, so it could never be enumerated — proven natively with a dlopen control), and the JSON sat in a `Resources/` subdirectory of a flat iOS framework. B02's combined render re-verified after the artifact change. | | F01 | Guard passes | 2026-07-20 (phase/error stage 4): the iOS start returns the classified error `XCFramework 'BadSlice' has no iOS simulator slice (available: ios-arm64).` with a rebuild remediation — the enricher reads the declared binary target's `Info.plist` when a `no such module` names it, and any miss degrades to the plain build failure. Daemon stays responsive. | | R01 | Guard passes | 2026-07-20 (phase/error stage 4): the start returns a classified session error — `Rendering the preview failed: JIT link could not resolve 3 symbol(s): _SCNVector3Zero, _OBJC_CLASS_$_SCNScene, _OBJC_CLASS_$_LPLinkMetadata` — naming the autolink closure's actual symbols, with the bounded list and an autolink remediation. The daemon stays responsive; rendering the closure remains named future work (`LC_LINKER_OPTION` scan). | -| R02 | Reproduced | English JSON, text, and localization resources rendered on macOS and iOS. Both selecting preview index 1 and an explicit single-preview Spanish localization control produced a blank or partial framebuffer on iOS. Native SwiftPM build passed and both locale directories were present in the staged bundle. | -| R03 | Reproduced | macOS and iOS rendered the generated color symbol, while the localized key remained `resource.title` and plist/Core Data lookup reported missing. The cold iOS Xcode build also spent about 49 seconds in one progress step. Re-verified 2026-07-15 under Xcode compile capture: the generated-sources half renders identically; the remaining gap is runtime-resource staging (out of the resolver's scope). | +| R02 | Guard passes | 2026-07-21: the original blank/partial framebuffer no longer reproduces, and with the fixture's Spanish assertion corrected (see Fixture Corrections) every surface renders `Recursos cargados` — the macOS control, the iOS single-preview control, and iOS index 1 after a live switch — alongside the JSON and text rows. The re-verification first found all surfaces rendering the English title, but a native harness against the healthy built bundle proved that was the fixture's own mechanism: `String(localized:bundle:locale:)` does not select the `.lproj` (its `locale:` parameter affects interpolation formatting only), so the 2026-07-15 fixture variant could never display Spanish even against correct staging. Original 2026-07-14 observation, for history: index 1 and the Spanish control produced a blank or partial framebuffer on iOS while the native build passed with both locale directories staged. | +| R03 | Reproduced | macOS and iOS rendered the generated color symbol, while the localized key remained `resource.title` and plist/Core Data lookup reported missing. The cold iOS Xcode build also spent about 49 seconds in one progress step. Re-verified 2026-07-15 under Xcode compile capture: the generated-sources half renders identically; the remaining gap is runtime-resource staging (out of the resolver's scope). Re-verified 2026-07-21: macOS unchanged (the generated color renders; `resource.title`, plist, and Core Data model still miss). iOS had regressed to a deterministic agent SIGILL — the generic `iOS Simulator` destination builds every arch and the build-log capture could return the x86_64 swift-frontend invocation — fixed the same day (#438, host-arch capture preference plus the foreign-arch triple strip); after the fix iOS renders the macOS-identical partial baseline again. The remaining gap on both platforms: `Bundle(for:)` on a class compiled into the JIT resolves to the agent process image, and only `Generated*Symbols.swift` files are rewritten to the framework wrapper (`applyResourceBundleRewrites`), so user-code bundle lookups miss resources that are present on disk in the built framework. | | W01 | Partial guard | Editing a dependency Swift file live changed `source version one` to `source version two` in a stable follow-up snapshot without restarting the session. The add/rename/remove variants are present in the fixture instructions but were not all exercised in this pass. Edit variant re-verified 2026-07-15 with the watcher fed by captured compile inputs; the fixture's Swift 6 language mode also forced two generated-source concurrency fixes (DesignTimeStore, window-state observer). | | W02 | Guard passes | 2026-07-16 (state-invalidation stage 4): a resource-only edit to `Resources/payload.json` fired the runtime-input tier — the daemon logged `Evidence change: re-running the native build` — and a stable follow-up snapshot rendered the new value; the revert refreshed back. Regression note on the original observation: on stage-3 code, neither an in-place nor an atomic-rename resource-only edit produces any watcher activity (the resource path cannot pass the exact-path filter), and a snapshot logs only clean MCP lines — the originally recorded "reload transition" therefore came from the operator's editor re-saving an open watched source file in the same burst, not from the resource edit. | | W03 | Guard passes | Both editor save styles reloaded on macOS: write-temp-then-rename-over and rename-away-then-recreate each updated the render to the new source value in a stable follow-up snapshot. | @@ -83,6 +84,19 @@ not a nonzero command exit. - Added a single-preview Spanish resource control and made locale selection explicit in the Foundation localization call. The prior environment-only variant did not actually select Foundation's localization locale. +- Corrected B04's resource assertion (2026-07-21): `Bundle.allFrameworks` + never lists a framework without ObjC classes, and a flat iOS framework's + resources belong at its root, not under `Resources/`. DynamicBadge now + carries an ObjC marker class, the JSON moved to the framework root, and + the preview resolves through `Bundle(for:)` with a distinct failure + state per stage (class registration, bundle identity, resource + presence, resource readability). +- Replaced the Spanish assertion mechanism again (2026-07-21): + `String(localized:bundle:locale:)` does not select the `.lproj` either (the + `locale:` parameter affects interpolation formatting only, proven natively + against the built bundle), so the title now resolves through the locale's + `.lproj` sub-bundle explicitly and shows `.lproj missing` or + `resource.title unresolved` when staging drops the directory or the key. - Split combined/static/dynamic/bad-slice XCFramework cases into separate SwiftPM package roots after a package-wide build let the bad slice contaminate supposedly isolated targets. diff --git a/examples/regress/binary-frameworks/FrameworkSources/DynamicBadge/DynamicBadgeMarker.m b/examples/regress/binary-frameworks/FrameworkSources/DynamicBadge/DynamicBadgeMarker.m new file mode 100644 index 00000000..1c484147 --- /dev/null +++ b/examples/regress/binary-frameworks/FrameworkSources/DynamicBadge/DynamicBadgeMarker.m @@ -0,0 +1,7 @@ +#import + +@interface DynamicBadgeMarker : NSObject +@end + +@implementation DynamicBadgeMarker +@end diff --git a/examples/regress/binary-frameworks/README.md b/examples/regress/binary-frameworks/README.md index 786233c8..a75f28a3 100644 --- a/examples/regress/binary-frameworks/README.md +++ b/examples/regress/binary-frameworks/README.md @@ -5,7 +5,11 @@ small, deterministic, and network-free: - `StaticBadge.xcframework` contains a static simulator archive and headers. - `DynamicBadge.xcframework` contains a dynamic simulator framework with an - `@rpath` install name and a bundled JSON resource. + `@rpath` install name, a root-level JSON resource (flat iOS frameworks + carry resources at the wrapper root), and an ObjC marker class — a + pure-C framework has no class to hand `Bundle(for:)`, so the marker is + what anchors bundle resolution at runtime (it also makes the framework + visible to `Bundle.allFrameworks`, which skips ObjC-less frameworks). - `BadSlice.xcframework` contains only a device slice. The generator also creates `Invalid.dylib`, which is not a Mach-O file. It is diff --git a/examples/regress/binary-frameworks/dynamic-only/Sources/DynamicBinaryFixture/DynamicBinaryPreview.swift b/examples/regress/binary-frameworks/dynamic-only/Sources/DynamicBinaryFixture/DynamicBinaryPreview.swift index 644a1fe1..d8284b56 100644 --- a/examples/regress/binary-frameworks/dynamic-only/Sources/DynamicBinaryFixture/DynamicBinaryPreview.swift +++ b/examples/regress/binary-frameworks/dynamic-only/Sources/DynamicBinaryFixture/DynamicBinaryPreview.swift @@ -4,14 +4,15 @@ import SwiftUI struct DynamicBinaryView: View { private var frameworkResource: String { - guard let bundle = Bundle.allFrameworks.first(where: { - $0.bundleURL.lastPathComponent == "DynamicBadge.framework" - }), - let url = bundle.url(forResource: "fixture-payload", withExtension: "json"), - let value = try? String(contentsOf: url, encoding: .utf8) - else { - return "framework resource missing" - } + guard let marker = NSClassFromString("DynamicBadgeMarker") + else { return "framework class not registered" } + let bundle = Bundle(for: marker) + guard bundle.bundleURL.lastPathComponent == "DynamicBadge.framework" + else { return "framework bundle unresolved (\(bundle.bundleURL.lastPathComponent))" } + guard let url = bundle.url(forResource: "fixture-payload", withExtension: "json") + else { return "framework resource missing" } + guard let value = try? String(contentsOf: url, encoding: .utf8) + else { return "framework resource unreadable" } return value.trimmingCharacters(in: .whitespacesAndNewlines) } diff --git a/examples/regress/binary-frameworks/generate-artifacts.sh b/examples/regress/binary-frameworks/generate-artifacts.sh index 8be1ea3c..3ca98d2b 100755 --- a/examples/regress/binary-frameworks/generate-artifacts.sh +++ b/examples/regress/binary-frameworks/generate-artifacts.sh @@ -9,7 +9,7 @@ DEVICE_SDK="$(xcrun --sdk iphoneos --show-sdk-path)" rm -rf "$BUILD_DIR" "$ARTIFACTS_DIR" mkdir -p "$BUILD_DIR/StaticBadge" "$BUILD_DIR/DynamicBadge.framework/Headers" -mkdir -p "$BUILD_DIR/DynamicBadge.framework/Modules" "$BUILD_DIR/DynamicBadge.framework/Resources" +mkdir -p "$BUILD_DIR/DynamicBadge.framework/Modules" mkdir -p "$ARTIFACTS_DIR" xcrun clang \ @@ -33,11 +33,14 @@ cp "$ROOT/FrameworkSources/DynamicBadge/module.modulemap" \ cp "$ROOT/FrameworkSources/DynamicBadge/Info.plist" \ "$BUILD_DIR/DynamicBadge.framework/Info.plist" cp "$ROOT/FrameworkSources/DynamicBadge/fixture-payload.json" \ - "$BUILD_DIR/DynamicBadge.framework/Resources/fixture-payload.json" + "$BUILD_DIR/DynamicBadge.framework/fixture-payload.json" xcrun clang \ -target arm64-apple-ios17.0-simulator \ -isysroot "$SIMULATOR_SDK" \ - -dynamiclib "$ROOT/FrameworkSources/DynamicBadge/DynamicBadge.c" \ + -dynamiclib \ + "$ROOT/FrameworkSources/DynamicBadge/DynamicBadge.c" \ + "$ROOT/FrameworkSources/DynamicBadge/DynamicBadgeMarker.m" \ + -framework Foundation \ -I "$ROOT/FrameworkSources/DynamicBadge" \ -install_name @rpath/DynamicBadge.framework/DynamicBadge \ -o "$BUILD_DIR/DynamicBadge.framework/DynamicBadge" diff --git a/examples/regress/runtime-resources/README.md b/examples/regress/runtime-resources/README.md index c04be6a9..7860c219 100644 --- a/examples/regress/runtime-resources/README.md +++ b/examples/regress/runtime-resources/README.md @@ -8,12 +8,20 @@ resource success. The preview reads all of the following from `Bundle.module`: - a plain text resource. Run `ResourcePreview.swift` through PreviewsMCP on macOS and iOS, then switch -from its English preview to its Spanish preview. Each view passes the intended -locale directly to Foundation localization. Missing staged bundles or resource -accessor sources should be reported separately from Swift compilation -failures. Compiled asset catalogs and Core Data models are covered by the Xcode -fixture because command-line SwiftPM copies those directory resources rather -than producing Xcode build outputs. +from its English preview to its Spanish preview. Each view resolves its title +through the requested locale's `.lproj` sub-bundle explicitly +(`Bundle.module.path(forResource:ofType:)` then `localizedString(forKey:)`), +so the title is `Recursos cargados` exactly when the Spanish directory is +staged and readable, `.lproj missing` when staging dropped it, and +`resource.title unresolved` when the directory exists without the key. The +explicit lookup is deliberate: `String(localized:bundle:locale:)` does not +select the `.lproj` (its `locale:` only affects interpolation formatting), so +a preview-level locale assertion built on it can never show Spanish even when +staging is correct. Missing staged bundles or resource accessor sources should +be reported separately from Swift compilation failures. Compiled asset +catalogs and Core Data models are covered by the Xcode fixture because +command-line SwiftPM copies those directory resources rather than producing +Xcode build outputs. `SpanishOnlyPreview.swift` is the control for separating localized resource loading from a failure that occurs only when selecting or switching to the diff --git a/examples/regress/runtime-resources/Sources/RuntimeResources/ResourcePreview.swift b/examples/regress/runtime-resources/Sources/RuntimeResources/ResourcePreview.swift index 4c913f2c..0f74b936 100644 --- a/examples/regress/runtime-resources/Sources/RuntimeResources/ResourcePreview.swift +++ b/examples/regress/runtime-resources/Sources/RuntimeResources/ResourcePreview.swift @@ -2,14 +2,14 @@ import Foundation import SwiftUI struct RuntimeResourceView: View { - private static let titleKey: String.LocalizationValue = "resource.title" - - private let locale: Locale + private let title: String private let payload: String private let textResourceLoaded: Bool init(locale: Locale = .current) { - self.locale = locale + title = Self.localizedTitle( + localeIdentifier: locale.language.languageCode?.identifier ?? locale.identifier + ) if let url = Bundle.module.url(forResource: "payload", withExtension: "json"), let data = try? Data(contentsOf: url), @@ -26,9 +26,19 @@ struct RuntimeResourceView: View { ) != nil } + private static func localizedTitle(localeIdentifier: String) -> String { + guard + let path = Bundle.module.path(forResource: localeIdentifier, ofType: "lproj"), + let bundle = Bundle(path: path) + else { return "\(localeIdentifier).lproj missing" } + return bundle.localizedString( + forKey: "resource.title", value: "resource.title unresolved", table: nil + ) + } + var body: some View { VStack(spacing: 10) { - Text(String(localized: Self.titleKey, bundle: .module, locale: locale)) + Text(title) .font(.headline) Text(payload) Label(