Imported from foscomputerservices/FOSUtilities (
.claude/skills/fosmvvm-swiftui-app-setup/SKILL.md). Install upstream withnpx skills add foscomputerservices/FOSUtilities --skill fosmvvm-swiftui-app-setup. Copyright stays with the author.
FOSMVVM SwiftUI App Setup
Read
shared/functional-discipline.mdbefore proceeding. Every rule below derives from it.
Maintain the App struct of a FOSMVVM SwiftUI application — the MVVMEnvironment, the test-view registry, the localization wiring, and the framework targets added by hand after generation.
Creating an app is
fosmvvm-bootstrap's job.swift run fosmvvm-bootstrap newemits a complete, buildable project for any of the three shapes — App struct, environment, test infrastructure, seededCLAUDE.mdand memory files. This skill picks up where that leaves off: the scaffolder generates once and does not maintain, and an app's App struct changes for as long as the app lives.
Conceptual Foundation
For full architecture context, see FOSMVVMArchitecture.md | OpenClaw reference
API catalog: check
../shared/api-catalog/FOSMVVM.md§ SwiftUI Support, § Versioning before hand-writing helpers.
The App struct is the entry point of a SwiftUI application. In FOSMVVM, it has three core responsibilities:
┌─────────────────────────────────────────────────────────────┐
│ @main App Struct │
├─────────────────────────────────────────────────────────────┤
│ 1. MVVMEnvironment Setup │
│ - Bundles (app + localization resources) │
│ - Deployment URLs (production, staging, debug) │
│ │
│ 2. Environment Injection │
│ - .environment(mvvmEnv) on WindowGroup │
│ - Custom environment values │
│ │
│ 3. Test Infrastructure (DEBUG only) │
│ - .testHost { } modifier for UI testing │
│ - registerTestingViews() for individual view testing │
└─────────────────────────────────────────────────────────────┘
Core Components
1. MVVMEnvironment
The MVVMEnvironment provides FOSMVVM infrastructure to all views:
private var mvvmEnv: MVVMEnvironment {
MVVMEnvironment(
appBundle: Bundle.main,
resourceBundles: [
MyAppViewModelsResourceAccess.localizationBundle,
SharedResourceAccess.localizationBundle
],
deploymentURLs: [
.production: .init(serverBaseURL: URL(string: "https://api.example.com")!),
.debug: .init(serverBaseURL: URL(string: "http://localhost:8080")!)
]
)
}
Key configuration:
appBundle- UsuallyBundle.main(the app bundle)resourceBundles- Array of localization bundles from your modulesdeploymentURLs- URLs for each deployment environment
Resource Bundle Accessors:
Each module that contains localization resources should provide a bundle accessor:
// In your ViewModels module (e.g., MyAppViewModels/ResourceAccess.swift)
public enum MyAppViewModelsResourceAccess {
public static var localizationBundle: Bundle { Bundle.module }
}
This pattern:
- Uses
Bundle.modulewhich SPM automatically provides for each module - Provides a clean public API for accessing the module's resources
- Keeps bundle access centralized in one place per module
2. Environment Injection
The MVVMEnvironment is injected at the WindowGroup level:
var body: some Scene {
WindowGroup {
MyView()
}
.environment(mvvmEnv) // ← Makes FOSMVVM infrastructure available
}
This makes the environment available to all views in the hierarchy.
3. Test Infrastructure
The test infrastructure enables UI testing with specific configurations:
Baseline: .testHost() (no-arg) for display-only apps. If the app has no typed
TestConfiguration and nothing reads an underTest flag, the minimal wiring is the
no-arg form — the FOSTestingUI harness swaps the registered view in on onAppear
regardless:
WindowGroup {
LandingPageView()
.testHost() // display-only: no decorator, no underTest needed
}
Adopt the .testHost { testConfiguration, testView in … } closure form (below) only
when a view must branch on a specific test scenario or seed the environment for a typed
config — not as the default.
.testHost { } modifier (typed-config opt-in):
var body: some Scene {
WindowGroup {
ZStack {
LandingPageView()
}
#if DEBUG
.testHost { testConfiguration, testView in
// Handle specific test configurations...
default:
testView
.onAppear {
underTest = ProcessInfo.processInfo.environment["__FOS_ViewModel"] != nil
}
}
#endif
}
}
Key points:
- Apply to the top-level view in WindowGroup (the outermost view in your hierarchy)
- This ensures the modifier wraps the entire view hierarchy to intercept test configurations
- Always include the
default:case - Detect test mode via
launchEnvironment, not process arguments. The FOSTestingUI harness (ViewModelDisplayTestCase.presentView) passes the target VM viaapp.launchEnvironment(__FOS_ViewModelType/__FOS_ViewModel/__FOS_TestConfiguration) and sets no launch arguments — soProcessInfo.processInfo.arguments.count > 1stays false underpresentViewand is unreliable. ReadProcessInfo.processInfo.environment["__FOS_ViewModel"]instead. - Sets
@State private var underTest = falseflag - Optional: Add specific test configurations for advanced scenarios
registerTestingViews() function:
@main struct MyApp: App {
init() {
MVVMEnvironment.registerTestingViews()
}
}
private extension MVVMEnvironment {
@MainActor static func registerTestingViews() {
#if DEBUG
registerTestView(LandingPageView.self)
registerTestView(DeviceCardView.self, designedFor: .scrolling)
registerTestView(SettingsView.self, designedFor: .navigation) // has a .toolbar
// ... register all ViewModelViews for individual testing
#endif
}
}
Key points:
- A
staticextension onMVVMEnvironment—registerTestView(_:)is static, so no instance is involved and the calls read unqualified - Called from
init(), and only frominit() - Registers every ViewModelView for isolated testing
#if DEBUGgoes inside the helper, not around the call — onlyregisterTestView(_:)'s body is DEBUG-only, so the whole helper compiles away to a no-op in release andinit()stays unguardeddesignedFor:states the production parents a view was designed for —.scrollingfor a view that lives inside aScrollView(a form card, a section of a longer page),.navigationfor one that contributes to a navigation ancestor (.toolbaritems,navigationTitle,.searchable,NavigationLinkdestinations), or both, which nest navigation-outermost as production does. Declared nothing, a scrolling-parent view compresses and buries its bottom controls beyond any tap's reach, and a navigation-parent view's toolbar items are absent from the accessibility tree — so the test is told its identifier is wrong, which is the one thing it is not. The declaration is a fact about the view's designed environment, stated once at registration so no two tests can disagree about it (SRP: one truth, one home) — not an escape hatch for a view that overflows its production container too, and there is no per-test override.scrollable:is deprecated; it forwards todesignedFor: .scrolling. Declare it on the view's design, not on the platform you happen to be testing — macOS renders.toolbaritems from the window whether or not.navigationis declared, so a view that looks fine there can be untestable on iOS
Timing is load-bearing. testHost() resolves the view under test before the first render, so registration from a computed property (var mvvmEnv), from .onAppear, or from .task arrives too late. When that happens the app stops with a diagnostic on stderr naming the missing ViewModel, listing what is registered, and showing the init() fix — do not work around it by registering later. See reference.md → Pattern 3.
When to Use This Skill
The App struct is generated once and then edited for the rest of the app's life. This skill is for the editing.
- Adding a framework target by hand, after bootstrap — Xcode's template defaults are wrong for FOSMVVM; see the checklist below.
- Standing up the first client-hosted ViewModel in an app that had none —
resourceBundles, the bundle accessor, and the two symptoms (missingLocalizationStore,noResourcePaths). - Registering a new
ViewModelViewso it can be driven in isolation under test. - Adding a deployment environment or changing a server URL.
- Adopting FOSMVVM in an existing SwiftUI app the scaffolder never created.
Creating a new app is not on this list. swift run fosmvvm-bootstrap new emits the App struct, the MVVMEnvironment, the test infrastructure, and the app's seeded CLAUDE.md and memory files, for all three project shapes. Reach for the scaffolder, then come back here when the app starts changing.
What This Skill Covers
| Concern | Where it lives | When you touch it |
|---|---|---|
MVVMEnvironment |
Computed property in the App struct | New resource bundle, new deployment URL |
| Test-view registration | init() of the App struct |
Every new ViewModelView |
| Client-hosted localization | resourceBundles + a module's ResourceAccess |
First client-hosted VM; every new resource-carrying framework |
| Server-hosted contract wiring | Shared module + server side | Every new server-fetched ViewModel |
| Deployment configuration | deploymentURLs + FOS-DEPLOYMENT |
New environment, changed host |
SOLID Is the Foundation
This app is built on FOSMVVM, which is built on the SOLID principles — deviations cause catastrophic failures (runtime type-identity mismatches, leaked domain types, SwiftUI identity churn) that surface far from their cause. Treat a SOLID violation as a hard stop.
- Source-of-truth ordering: SOLID → FOSMVVM architecture → this app's code.
- Add ViewModels / Requests / Fields / Views / tests via the
fosmvvm-*generator skills rather than hand-rolling — they encode the SOLID patterns (noun-first requests, one file per ViewModel, the domain-free ViewModel boundary, theSPMLibrariesumbrella, …). - Key rules: a ViewModel is a projection of data, never the data (the Factory adapts);
the ViewModel module never imports the domain/wire module; consume SPM products through
the one
SPMLibrariesumbrella;vmIdis stable data identity, never a throwaway.
Encapsulation Is the Precondition SOLID Assumes
Encapsulation is not a SOLID principle and not something a "SOLID-clean" verdict
certifies — it is the precondition SOLID relies on. SOLID governs structure/dependency
direction; encapsulation governs state visibility. SOLID's benefits degrade silently
without it (SRP is satisfied by all-public var; OCP is "followed" while an extension pokes
another type's hidden state and the safe-extension payoff evaporates), so review it
separately. Scalable, maintainable, testable apps require perfect encapsulation to run
predictably over time — break one wall and it's the small hole in the dam that cascades.
- Stringly-typing is the encapsulation break. A
Stringused as an identity/route/key/ token has no wall — anyone can mint, parse, or route on it. Prefer a typed/opaque value; never expose aString"just for a test" or "just to derive X." - Don't publish the representation. Never state a sealed type's internal shape (encoded keys, token format) in DocC / CHANGELOG / README — it becomes a schema others parse or forge. State the contract (opaque; round-trips; stable within a major version), not the shape.
- Test the contract, not the representation (equality, determinism, "old data still decodes"), never an incidental encoded byte layout.
Also seed an **"API Discovery"** entry so future sessions on the app check the FOSUtilities
catalog before reinventing shipped API. Reference the discovery skill **by name only — never
by a filesystem path** (the catalog files live inside the installed plugin, not in the app
repo's `.claude/skills/`). Drop-in:
```markdown
## API Discovery
Before writing helpers for JSON/Codable, dates, networking/URLSession, strings,
collections, async bridging, versioning, model identifiers, or testing, invoke the
`fosutilities-api-catalog` skill — FOSUtilities likely already provides the API.
Prefer the catalogued API over hand-rolled code.
How to Use This Skill
Invocation: /fosmvvm-swiftui-app-setup
Start by reading the App struct that already exists. Every task below is an edit to a file the scaffolder wrote, or to one a previous session extended — not a fresh generation. Read it first, and match it.
The recurring edits, and where each lands:
| You are… | Edit | Then verify |
|---|---|---|
Adding a ViewModelView |
registerTestView(_:) in the App's init() |
Drive it in isolation under test |
| Adding a resource-carrying framework | resourceBundles + a {Module}ResourceAccess |
Translations round-trip in that module's tests |
| Standing up the first client-hosted VM | resourceBundles, and read Client-Hosted Localization below |
No missingLocalizationStore / noResourcePaths |
| Adding a deployment environment | deploymentURLs + the FOS-DEPLOYMENT plist key |
Deployment.current resolves as intended |
| Adding a framework target by hand | The new-framework-target checklist below | swift package fosmvvm-doctor |
Registration timing is load-bearing and is the one edit that fails silently if placed
wrong — it belongs in init(), not in a computed property, .onAppear, or .task. The
Core Components section above states why.
When the edit is structural rather than in-file — a new target, changed signing, a new
test bundle — the project settings are the scaffolder's territory and fosmvvm-doctor's to
audit. See the hand-off section near the end of this file.
Key Patterns
MVVMEnvironment: built once by a static factory, held in @State
@main
struct MyApp: App {
@State private var mvvmEnv = makeMVVMEnvironment()
var body: some Scene {
WindowGroup { … }
.environment(mvvmEnv)
}
}
private extension MyApp {
@MainActor static func makeMVVMEnvironment() -> MVVMEnvironment {
MVVMEnvironment(
appBundle: Bundle.main,
resourceBundles: [MyAppViewModelsResourceAccess.localizationBundle],
deploymentURLs: [...]
)
}
}
Why stored, and why a factory. body is evaluated repeatedly. A computed
var mvvmEnv: MVVMEnvironment { … } therefore builds a new MVVMEnvironment on every
pass and hands .environment() a different instance each render — churn on a value that
should be stable for the app's lifetime, and wasted work resolving bundles and URLs. @State
holds one instance; the static factory keeps the construction out of the property
declaration, so DEBUG/RELEASE variation, pinned sessions, and credential providers all have
somewhere to live without turning the declaration into a wall of arguments.
Earlier revisions of this skill specified a computed property, on the reasoning that it kept initialization separate and allowed per-configuration variation. The static factory satisfies both without rebuilding per render, and is what
fosmvvm-bootstrapemits for every shape.
Test Detection Pattern
The default test detection uses process arguments:
@State private var underTest = false
// In .testHost default case:
testView
.onAppear {
// Right now there's no other way to detect if the app is under test.
// This is only debug code, so we can proceed for now.
underTest = ProcessInfo.processInfo.environment["__FOS_ViewModel"] != nil
}
Why this approach?
- Simple and reliable for DEBUG builds
- No additional dependencies
- Process arguments are set by test runner
Register All ViewModelViews
Every ViewModelView should be registered for testing:
private extension MVVMEnvironment {
@MainActor static func registerTestingViews() {
#if DEBUG
// Landing Page
registerTestView(LandingPageView.self)
// Settings
registerTestView(SettingsView.self)
registerTestView(ProfileView.self)
// Dashboard
registerTestView(DashboardView.self)
registerTestView(CardView.self)
#endif
}
}
Organization tips:
- Group by feature/screen with comments
- Alphabetical order within groups
- One view per line for easy scanning
Common Customizations
The Environment Surface: mvvmEnv + One App State
The root injects exactly two things — the framework environment and one top-level
Application State @Observable final class:
var body: some Scene {
WindowGroup {
MyView()
}
.environment(mvvmEnv)
.environment(appState)
}
Resist growing this list. Every custom @Entry and every additional @Observable
injected here is an obligation on every preview and test host that renders the
subtree — forget one .environment(...) and an @Environment(X.self) read crashes,
while a custom-keyed value silently defaults. App-state-shaped values (selection,
filters, navigation, session) belong as properties on the App State class, reaching
ViewModels as scalars through .bind(appState: .init(...)). One class also gives you
the persistence seam for free: store it, and the app resumes where the user left off.
The question any additional entry must answer: why can't this live on the App State?
See Architecture Patterns → One Top-Level App State, Not an Environment of
Entries.
The Test-Host Seam Arrives With the App State
A skeleton app whose only environment is mvvmEnv correctly uses plain
.testHost() — there is no state to transport, so it ships no TestConfiguration
and no decorator (ruled 2026-08-25). The seam arrives the day
.environment(appState) does. When you add the top-level App State, add the
pair together:
- a
TestConfiguration(Codableenum) whose cases carry the state a UI test needs the app to mirror, and - the decorator form —
.testHost { testConfiguration, testView in … }— building env state from the decoded configuration, so the hosted view's environment mirrors the injected VM stub instead of production state.
A TestConfiguration with a single payload-free case that no test constructs is
a dead seam — scaffolding noise that teaches nothing. See Advanced Test
Configurations below for the decorator's shape.
Conditional Test Registration
You can conditionally register views based on build configuration:
private extension MVVMEnvironment {
@MainActor static func registerTestingViews() {
#if DEBUG
registerTestView(LandingPageView.self)
#if INCLUDE_ADMIN_FEATURES
registerTestView(AdminPanelView.self)
#endif
#endif
}
}
Advanced Test Configurations
You can add specific test configurations in .testHost:
.testHost { testConfiguration, testView in
switch try? testConfiguration.fromJSON() as MyTestConfiguration {
case .specificScenario(let data):
testView.environment(MyState.stub(data: data))
.onAppear { underTest = true }
default:
testView
.onAppear {
underTest = ProcessInfo.processInfo.environment["__FOS_ViewModel"] != nil
}
}
}
Wiring the App to Its Resources
The three things the App struct has to get right about resources — where a module's bundle comes from, what deploymentURLs says when there is no server, and what the App file is allowed to import.
ResourceAccess.swift — the two forms
Each module carrying YAML resources exposes a single localizationBundle accessor. The body differs by build system:
Xcode framework target (Bundle(for:)):
public enum {Module}ResourceAccess {
private final class ResourceAccessClass {}
public static var localizationBundle: Bundle {
Bundle(for: ResourceAccessClass.self)
}
}
SPM library target (Bundle.module):
public enum {Module}ResourceAccess {
public static var localizationBundle: Bundle { Bundle.module }
}
Both are consumed identically from the App struct:
resourceBundles: [{Module}ResourceAccess.localizationBundle]
Wholly client-hosted apps: empty deploymentURLs
A FOSMVVM app may be entirely client-hosted (every ViewModel built via @ViewModel(options: [.clientHostedFactory]), no project server). For these apps, deploymentURLs is legitimately empty:
deploymentURLs: [Deployment: MVVMEnvironment.URLPackage]()
Do not invent placeholder URLs. An empty dictionary is the correct expression of "this app talks to no server."
Any app with client-hosted ViewModels — wholly client-hosted or mixed — must also wire up client-side localization resources; see "Client-Hosted Localization" below.
Canonical app-target imports
The App struct file's import set is small and stable:
import FOSFoundation
import FOSMVVM
import SwiftUI
import {SharedModule} // typically `ViewModels`
// + any per-module ResourceAccess imports if the accessor's module differs
If the App struct references types from Models or other implementation-side targets at top level, that is a smell — App-level wiring should go through the shared module.
Client-Hosted Localization
Read this before adding the app's FIRST @ViewModel(options: [.clientHostedFactory])
ViewModel — whether the app is wholly client-hosted or a server-hosted app gaining one
client-hosted screen. Every failure below compiles clean and breaks at runtime, far from
the missing configuration.
The concept — who resolves the strings
- A server-hosted ViewModel is localized on encode: the client receives an already-localized ViewModel and needs no localization resources at all.
- A client-hosted ViewModel resolves its
@LocalizedStrings on the client, at bind time, viaMVVMEnvironment.clientLocalizationStore— which is built lazily from the environment'sresourceBundles(MVVMEnvironment.swift:170-178). If no configured bundle carries the ViewModel's YAML, resolution fails.
Symptoms when the YAML isn't reachable:
- App at runtime:
ViewModelViewError.missingLocalizationStore/YamlStoreError.noResourcePathssurfaces on the client-hosted bind path (ViewModelView.swift:588-589) — printed asViewModel Bind Error:, after which the view silently falls back to.stub(). - Tests:
YamlStoreError.noResourcePathsfromloadLocalizationStore.
A server-hosted app has needed none of this, so its MVVMEnvironment legitimately has no
resourceBundles — the first client-hosted ViewModel is exactly when the gap fires.
The resource-carrying framework (overlay-based Xcode apps)
Bundle.main works for a fresh SwiftUI app (YAML in the app target); Bundle.module works
for SPM. Neither applies when the app overlays shared source via synchronized folders
(PBXFileSystemSynchronizedRootGroup) from a directory outside the app project. There, the
client YAML must ride a framework target that ships the .yml files and exposes its own
bundle via the ResourceAccess pattern (Bundle(for:) form — see "ResourceAccess.swift —
the two forms" above), fed to the environment:
MVVMEnvironment(
appBundle: .main,
resourceBundles: [ClientViewModelsResourceAccess.localizationBundle],
// no resourceDirectoryName — see the flattening gotcha below
...
)
Naming: call the accessor localizationBundle (the established convention). Never name
it clientLocalizationStore — that collides with FOSMVVM's
MVVMEnvironment.clientLocalizationStore: LocalizationStore? (different type, same name →
confusing call sites).
New-framework-target checklist (Xcode template defaults are wrong)
When client-hosted ViewModels live in their own Xcode framework target, Xcode's template leaves it mis-configured in ways the compiler won't catch. Set all five:
DEVELOPMENT_TEAM— the template leaves it empty (does not inherit the app's team) → ad-hoc signing → the FOS frameworks it embeds carry a different Team ID than the app → macOS hardened-runtime library validation refuses the load at test time:"…FOSFoundation.framework … not valid for use in process: … different Team IDs". The error blames the FOS framework, not the mis-signed framework target — hours lost if you don't know. Set it to the app's team.BUILD_LIBRARY_FOR_DISTRIBUTION = NO— withYES,@ViewModel/@LocalizedStringmacro expansions can't be represented in the generated.swiftinterface:unknown attribute 'MyVM.LocalizedString'.- Deployment targets aligned to the app (template defaults lower).
SWIFT_VERSION = 6.0(template defaults to Swift 5).- Link and embed
SPMLibrariesonly — never add FOSFoundation/FOSMVVM to the framework directly; that compiles a second copy of the FOS types →TypeA != TypeAacross target boundaries (see "The SPMLibraries umbrella" — this is the LSP/type-identity rule).
The resource-flattening gotcha (resourceDirectoryName)
Xcode flattens a framework target's grouped resources into the bundle's resource
root — on-disk Resources/ViewModels/Foo.yml lands at …/Resources/Foo.yml, not under
a ViewModels/ subdirectory. Consequences:
- In
MVVMEnvironment, pass NOresourceDirectoryName.nilcoalesces to""(MVVMEnvironment.swift:174-176), and the YAML search recurses from the bundle's resource roots (findFileswalks the whole tree — URL+Files.swift:23-37; roots at YamlLocalizationStore.swift:103-113). Passing the on-disk subfolder name ("ViewModels") silently searches a path that does not exist in the built bundle → zero files →.noResourcePaths. - In tests, pass the empty string explicitly:
loadLocalizationStore(bundle: …, resourceDirectoryName: ""). The parameter defaults to"Resources"(LocalizableTestCase.swift:54), which works on macOS (Contents/Resources/Resourcesexists for SPM test bundles) but throws.noResourcePathson iOS's flat bundles.""recurses and is correct on both platforms.
Testing reality: run client-VM-framework tests on the iOS Simulator
xcodebuild build-for-testing on macOS fails at link time (Ld <framework>) when a
framework target links the SPM package products: with test targets in the graph, the FOS
products build as separate dynamic PackageFrameworks/*.frameworks, SPMLibraries no
longer contains the FOS symbols, and the framework can't resolve them (undefined FOS
symbols). This is a shared Xcode limitation — it reproduces identically across multiple
independent FOSMVVM projects, so do not debug it as a project bug. The iOS Simulator
builds, links, loads, and passes cleanly (including expectTranslations); the macOS app
still builds — only build-for-testing is affected.
Default the test plans/schemes for client-VM frameworks to an iOS Simulator destination and note why in the scheme or test docs.
Server-Hosted ViewModel Contract Wiring (Both Sides)
How a client reaches a server-hosted ViewModel. Mainstream REST instincts —
"namespace the API under /admin", "make the client URL match the server route" — fight
the FOSMVVM model and cause 404s. FOSMVVM derives the path from the request type on
both sides, so neither side invents a URL. Four rules:
Rule 1 — A ViewModelRequest's path is derived from its TYPE and is globally unique.
You never need .grouped("string") to namespace ViewModels — there are no collisions to
avoid. Register on app.routes; the client points at a clean host, and the two paths agree
automatically because neither side invents one. Canonical — FOSShowcase
Sources/WebServer/routes.swift:
let unauthGroup = app.routes
try unauthGroup.register(viewModel: LandingPageViewModel.self) // served at the type-derived path
Rule 2 — Middleware ≠ path. Auth (mTLS client-cert, etc.) is applied with
.grouped(SomeMiddleware()), which adds no path segment — never .grouped("admin")
(a string), which adds a path the type-derived client resolver cannot reproduce. To gate a
ViewModel behind an admin contract:
app.grouped(AdminClientCertMiddleware()).register(viewModel: AdminInfoViewModel.self) // gate, NO prefix
Rule 3 — Base URLs are clean hosts, never …/path. The client MVVMEnvironment base
URL is scheme + host + port only. FOSShowcase Sources/SwiftUIApp/FOSShowcaseApp.swift:
deploymentURLs: [.debug: URL(string: "http://localhost:8080")!] // no path
requestURL derives the path from the request type and discards any base-URL path —
that is correct by design, not a bug.
Rule 4 — File structure: one folded tree, a shared contract module, resources as a server-side sibling. The whole system (server + every client + shared contract + resources + mirrored tests) lives in ONE directory (see "File Organization Conventions" and the canonical tree above). Placements that matter for the contract:
- The shared contract module (
ViewModels) holds only what both sides must agree on —ViewModels/,Requests/,Fields/,Versioning/— pure Swift, no resources. - Localization
*.ymlare server-only resources in a siblingSources/Resources/tree (mirroring the module),.copy'd by the server target (and any server-rendered web client, and tests) — FOSShowcase.copy("../Resources"). Deferred localization means the server resolves all strings at encode time, so a release native client decodes an already-localized ViewModel and needs no strings. The*.ymltherefore must NOT live in the shared contract module — the client links that module and would ship the strings. (A wholly client-hosted app with no server is the exception: it resolves localization itself and legitimately bundles the resources — see "Wholly client-hosted apps" above.) ViewModelFactories/Controllers/DataModelsare server-only;Viewsare client-only.- The native app is a target in the one tree's root
.xcodeproj(apps require Xcode), sharing the contract module directly — not a separate bolted-on XcodeGen project. Tests/mirrorsSources/one-to-one; version baselines commit underTests/.../.VersionedTestJSON/.
Fuller treatment: FOSMVVMArchitecture.md "Project Structure", "What Belongs Where", "File Organization Conventions".
Anti-drift callout. Do not
.grouped("string")a ViewModel route; do not put a path on the client base URL; if a fetch 404s, you added a stray path segment — do not change FOSMVVM. The server path and client path agree automatically because neither side invents one.
The Xcode project is the scaffolder's job, not this skill's
fosmvvm-bootstrap emits the project.yml, the target graph, the SPMLibraries umbrella, the
signing settings, and the test-target wiring for all three project shapes. It is the source of
truth for project structure; this skill does not restate it.
- Creating a project —
swift run fosmvvm-bootstrap new. See the Creating a Project article. - Auditing an existing one —
swift package fosmvvm-doctor, which checks the settings this section used to list (SWIFT_VERSION,BUILD_LIBRARY_FOR_DISTRIBUTION,TEST_HOST,DEVELOPMENT_TEAM,CODE_SIGN_STYLE, the hardened runtime, the link and embed graph, deployment floors). - Why the umbrella exists — FOSMVVM Architecture → The SPMLibraries umbrella.
The scaffolder generates a project once and does not maintain it: after the finishing checklist
converts the groups to synchronized folders, the .xcodeproj is hand-maintained. Everything below
is about that phase — the app's whole life after generation.
Naming Conventions
| Concept | Convention | Example |
|---|---|---|
| App struct | {Name}App |
MyApp, StoreApp |
| Main file | {Name}App.swift |
MyApp.swift |
| MVVMEnvironment property | mvvmEnv |
Always mvvmEnv |
| Test flag | underTest |
Always underTest |
Deployment Configuration
FOSMVVM supports deployment detection via Info.plist:
CI Pipeline Sets:
FOS_DEPLOYMENT build setting (e.g., "staging" or "production")
↓
Info.plist Contains:
FOS-DEPLOYMENT = $(FOS_DEPLOYMENT)
↓
Runtime Detection:
FOSMVVM.Deployment.current reads from Bundle.main.infoDictionary
Local development override:
- Edit Scheme → Run → Arguments → Environment Variables
- Add:
FOS-DEPLOYMENT = staging
See Also
- Architecture Patterns - Mental models and patterns
- FOSMVVMArchitecture.md - Full FOSMVVM architecture
- fosmvvm-viewmodel-generator - For creating ViewModels
- reference.md - Complete file templates
Version History
| Version | Date | Changes |
|---|---|---|
| 1.0 | 2026-01-23 | Initial skill for SwiftUI app setup |
| 1.1 | 2026-01-24 | Update to context-aware approach (remove file-parsing/Q&A). Skill references conversation context instead of asking questions or accepting file paths. |
| 1.2 | 2026-04-27 | Add "Code Signing for SPMLibraries Umbrella Frameworks" section documenting the macOS hardened-runtime + ad-hoc-signed PackageFrameworks Team ID mismatch and the com.apple.security.cs.disable-library-validation entitlement fix. |
| 1.3 | 2026-05-03 | Add "Project File Structure" section covering Xcode-project vs. SPM layout, the two ResourceAccess forms (Bundle(for:) vs. Bundle.module), wholly client-hosted apps with empty deploymentURLs, and canonical app-target imports. Add Template 6 to reference.md for a wholly client-hosted Xcode-project app. |
| 1.4 | 2026-05-03 | Add "Generating the Xcode Project (XcodeGen)" section with declarative project setup (SWIFT_VERSION, BUILD_LIBRARY_FOR_DISTRIBUTION = NO, signing, SPMLibraries umbrella wiring) so the .xcodeproj is regenerable from a committed project.yml. Add Template 7 to reference.md with a complete project.yml. |
| 1.5 | 2026-07-02 | Add "File Organization Conventions" (canonical owner): one type per file, collection grouped in a container-named directory (ViewModels/Docks/), the same grouping repeated across Views/Factories/Tests, and Tests/ mirrors Sources/. Canonical tree now demonstrates the grouping. (backlog B2/L59; other skills reference this.) |
| 1.6 | 2026-07-02 | BLOCKERS. Add "Server-Hosted ViewModel Contract Wiring (Both Sides)": type-derived globally-unique paths (no .grouped("string")), middleware≠path, clean-host base URLs, resources server-only in sibling Sources/Resources/, native app in root .xcodeproj, Tests mirror Sources; anti-drift callout; grounded in FOSShowcase routes.swift/FOSShowcaseApp.swift (C1). Add the SPMLibraries type-identity rationale (TypeA != TypeA across targets; FOS relies on type comparison) + "do not link per-target" callout; retitled umbrella REQUIRED (was "Optional") (C2). |
| 1.7 | 2026-07-02 | Fold in the build-verified fosmvvm-app-project-template.md (since removed) (copied into this repo): Option-A source inclusion, singular BUILD_LIBRARY_FOR_DISTRIBUTION (fixed the plural no-op typo throughout), {Base}UnitTests/{Base}UITests naming, TEST_HOST pin, app-hosted tests, supportedDestinations, .xctestplan caveat (C3). .testHost() no-arg baseline + underTest detection via launchEnvironment (__FOS_ViewModel) not arguments.count — verified against ViewModelViewTestCase.presentView (C4). "Lifecycle: scaffolds not maintains" — synchronized folders, commit the .xcodeproj, keep strict-concurrency (C5). Reinforced SystemVersion+<App>.swift naming (C6). |
| 1.8 | 2026-07-02 | Add "Seed the App's CLAUDE.md": recommend the scaffolded app repo adopt a "SOLID Is the Foundation" project-conventions entry (drop-in template) so downstream apps inherit FOSMVVM's SOLID discipline and point future sessions at the fosmvvm-* skills. |
| 1.9 | 2026-07-03 | Wire in the FOSUtilities API catalog: pointer to ../shared/api-catalog/FOSMVVM.md (§ SwiftUI Support, § Versioning) near the top, and an "API Discovery" drop-in for the seeded app CLAUDE.md referencing the fosutilities-api-catalog skill by name only (never a filesystem path — the catalog lives in the installed plugin). |
| 1.10 | 2026-07-23 | Add "Client-Hosted Localization" (field feedback from standing up a first .clientHostedFactory VM in an overlay-based Xcode app): encode-time vs bind-time localization concept + missingLocalizationStore/noResourcePaths symptoms; the resource-carrying framework pattern for overlay projects; the five framework-target settings (DEVELOPMENT_TEAM, BUILD_LIBRARY_FOR_DISTRIBUTION, deployment targets, SWIFT_VERSION, SPMLibraries-only linking); the Xcode resource-flattening gotcha (resourceDirectoryName nil ⇒ "" recurses; tests must pass "", not the "Resources" default); client-VM-framework tests default to the iOS Simulator (macOS build-for-testing PackageFrameworks link failure); localizationBundle naming (never clientLocalizationStore). All claims verified against MVVMEnvironment.swift / YamlLocalizationStore.swift / URL+Files.swift / LocalizableTestCase.swift / ViewModelView.swift. |
| 2.0 | 2026-08-24 | Re-cut around the app's life, not its creation. fosmvvm-bootstrap (shipped 0.14.0) now emits the App struct, MVVMEnvironment, test infrastructure, seeded CLAUDE.md and memory files for all three shapes, so this skill's project-creation half was superseded and had begun to drift — its project.yml table still read ENABLE_HARDENED_RUNTIME: YES for app and tests, while the templates set Debug NO / Release YES because YES in Debug kills macOS UI testing. Removed: the XcodeGen section, the project file tree, the code-signing section, the file-template list, and the seeded-CLAUDE.md instructions — all now the scaffolder's, and audited by fosmvvm-doctor. Kept and re-parented: MVVMEnvironment, test-view registration, the resource wiring, Client-Hosted Localization (including the hand-added-framework checklist and the resourceDirectoryName flattening gotcha), server-hosted contract wiring, deployment configuration. The SPMLibraries type-identity doctrine moved to .claude/docs/FOSMVVMArchitecture.md, discharging the never-done item from the bootstrap design §6.7 — it had lived only in this skill and in a template that ships out to customers. |