Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
04c186b
chore(ios): add Apollo iOS dependency and codegen config
Ur-imazing Feb 25, 2026
1d7d4c5
feat(ios): add GetWatchExperience query and generated types
Ur-imazing Feb 25, 2026
5e5f4a7
feat(ios): add GraphQLContentClient adapter for ContentClient
Ur-imazing Feb 25, 2026
eeedf0c
docs(ios): add GraphQL and codegen section to readme, ignore Apollo C…
Ur-imazing Feb 25, 2026
0cf6024
fix(ci): pass STRAPI_API_TOKEN and NEXT_PUBLIC_GRAPHQL_URL to web bui…
Ur-imazing Feb 25, 2026
2a3b753
chore(mobile-ios): codegen cwd requirement in readme, iOS-only package
Ur-imazing Feb 25, 2026
e95cc9b
fix(mobile-ios): document stubbed body/state, only use non-empty Prom…
Ur-imazing Feb 25, 2026
da2260a
fix(mobile-ios): derive state from publishedAt, document stubbed body
Ur-imazing Feb 25, 2026
87878bc
revert(ci): remove .env.local workaround from build step
Ur-imazing Feb 25, 2026
20ac8a9
feat(mobile-ios): add init(apollo:) for DI and fix SwiftLint in Graph…
Ur-imazing Feb 25, 2026
369ae3f
feat(mobile-ios): test call, Strapi token from .env at build, GraphQL…
Ur-imazing Feb 25, 2026
42aa770
chore(mobile-ios): debug vs release info plists for ATS and app store
Ur-imazing Feb 25, 2026
ce8837e
chore(mobile-ios): exclude generated code from swiftlint
Ur-imazing Feb 25, 2026
cae66e8
fix(mobile-ios): address CodeRabbit review
Ur-imazing Feb 25, 2026
383815e
fix(mobile-ios): address CodeRabbit — no token in binary, GraphQL URL…
Ur-imazing Feb 25, 2026
06aa284
fix(mobile-ios): restore Debug-only token from apps/cms/.env for Curs…
Ur-imazing Feb 25, 2026
d814e2d
refactor(mobile-ios): move content wiring out of ForgeApp into factory
Ur-imazing Feb 26, 2026
f66c004
feat(mobile-ios): enforce MVVM with WatchHomeViewModel and Data/ViewM…
Ur-imazing Feb 26, 2026
5b809aa
docs(mobile-ios): document MVVM and ForgeMobile layout in README
Ur-imazing Feb 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,6 @@ terraform.tfstate.*
# Xcode user state (do not commit)
**/xcuserdata/
**/*.xcuserstate

# iOS app generated token (from apps/cms/.env at build time)
mobile/ios/App/ForgeApp/Generated/
3 changes: 3 additions & 0 deletions apps/cms/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ ENCRYPTION_KEY=
DATABASE_CLIENT=sqlite
DATABASE_FILENAME=.tmp/data.db

# Full-access API token for local Strapi (clients use as Bearer token; create in Admin → Settings → API Tokens)
STRAPI_FULL_ACCESS_TOKEN=

# Web integration (apps/web expects these; set in apps/web/.env)
# Revalidate: web POST /api/revalidate expects x-forge-revalidate-token header (future Strapi webhook on publish)
STRAPI_REVALIDATE_TOKEN=
Expand Down
2 changes: 2 additions & 0 deletions mobile/ios/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Apollo iOS CLI symlink (created by apollo-cli-install)
apollo-ios-cli
4 changes: 4 additions & 0 deletions mobile/ios/.swiftlint.yml
Original file line number Diff line number Diff line change
@@ -1,2 +1,6 @@
excluded:
- .build
# Apollo codegen output; do not edit
- Sources/ForgeMobile/Generated
# Build-generated token from apps/cms/.env
- App/ForgeApp/Generated
41 changes: 41 additions & 0 deletions mobile/ios/App/ForgeApp.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,17 @@

/* Begin PBXBuildFile section */
A1B2C3D4E5F600000001 /* ForgeApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F600000002 /* ForgeApp.swift */; };
A1B2C3D4E5F60000001C /* AppContentRepositoryFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60000001D /* AppContentRepositoryFactory.swift */; };
A1B2C3D4E5F600000019 /* StrapiToken.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F600000017 /* StrapiToken.swift */; };
/* End PBXBuildFile section */

/* Begin PBXFileReference section */
A1B2C3D4E5F60000001D /* AppContentRepositoryFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppContentRepositoryFactory.swift; sourceTree = "<group>"; };
A1B2C3D4E5F600000002 /* ForgeApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForgeApp.swift; sourceTree = "<group>"; };
A1B2C3D4E5F600000003 /* ForgeApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ForgeApp.app; sourceTree = BUILT_PRODUCTS_DIR; };
A1B2C3D4E5F60000001A /* Info-Debug.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "Info-Debug.plist"; sourceTree = "<group>"; };
A1B2C3D4E5F60000001B /* Info-Release.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "Info-Release.plist"; sourceTree = "<group>"; };
A1B2C3D4E5F600000017 /* StrapiToken.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StrapiToken.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */
Expand Down Expand Up @@ -45,11 +51,23 @@
A1B2C3D4E5F600000006 /* ForgeApp */ = {
isa = PBXGroup;
children = (
A1B2C3D4E5F60000001D /* AppContentRepositoryFactory.swift */,
A1B2C3D4E5F600000018 /* Generated */,
A1B2C3D4E5F600000002 /* ForgeApp.swift */,
A1B2C3D4E5F60000001A /* Info-Debug.plist */,
A1B2C3D4E5F60000001B /* Info-Release.plist */,
);
path = ForgeApp;
sourceTree = "<group>";
};
A1B2C3D4E5F600000018 /* Generated */ = {
isa = PBXGroup;
children = (
A1B2C3D4E5F600000017 /* StrapiToken.swift */,
);
path = Generated;
sourceTree = "<group>";
};
A1B2C3D4E5F600000012 /* Package product dependencies */ = {
isa = PBXGroup;
children = (
Expand All @@ -64,6 +82,7 @@
isa = PBXNativeTarget;
buildConfigurationList = A1B2C3D4E5F60000000F /* Build configuration list for PBXNativeTarget "ForgeApp" */;
buildPhases = (
A1B2C3D4E5F600000016 /* Generate Strapi token */,
A1B2C3D4E5F600000007 /* Sources */,
A1B2C3D4E5F600000008 /* Frameworks */,
A1B2C3D4E5F600000009 /* Resources */,
Expand Down Expand Up @@ -116,6 +135,24 @@
};
/* End PBXProject section */

/* Begin PBXShellScriptBuildPhase section */
A1B2C3D4E5F600000016 /* Generate Strapi token */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Generate Strapi token";
outputPaths = (
"$(PROJECT_DIR)/ForgeApp/Generated/StrapiToken.swift",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "mkdir -p \"${PROJECT_DIR}/ForgeApp/Generated\"\nOUT=\"${PROJECT_DIR}/ForgeApp/Generated/StrapiToken.swift\"\nif [ \"${CONFIGURATION}\" = \"Release\" ]; then\n echo '// Generated at build time — do not edit. Release: no token in binary.' > \"$OUT\"\n echo 'let kStrapiFullAccessToken: String? = nil' >> \"$OUT\"\nelse\n ENV_FILE=\"${PROJECT_DIR}/../../../apps/cms/.env\"\n if [ -f \"$ENV_FILE\" ]; then\n TOKEN=$(grep '^STRAPI_FULL_ACCESS_TOKEN=' \"$ENV_FILE\" 2>/dev/null | sed 's/^STRAPI_FULL_ACCESS_TOKEN=//' | tr -d '\\n' | sed 's/^ *//;s/ *$//')\n else\n TOKEN=\"\"\n fi\n if [ -z \"$TOKEN\" ]; then\n echo '// Generated at build time — do not edit. Debug: set STRAPI_FULL_ACCESS_TOKEN in apps/cms/.env or scheme.' > \"$OUT\"\n echo 'let kStrapiFullAccessToken: String? = nil' >> \"$OUT\"\n else\n ESCAPED=$(printf '%s' \"$TOKEN\" | sed 's/\\\\/\\\\\\\\/g; s/\"/\\\\\"/g')\n printf '// Generated at build time — do not edit. Debug only.\nlet kStrapiFullAccessToken: String? = \"%s\"\n' \"$ESCAPED\" > \"$OUT\"\n fi\nfi\n";
};
/* End PBXShellScriptBuildPhase section */

/* Begin PBXResourcesBuildPhase section */
A1B2C3D4E5F600000009 /* Resources */ = {
isa = PBXResourcesBuildPhase;
Expand All @@ -131,7 +168,9 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A1B2C3D4E5F60000001C /* AppContentRepositoryFactory.swift in Sources */,
A1B2C3D4E5F600000001 /* ForgeApp.swift in Sources */,
A1B2C3D4E5F600000019 /* StrapiToken.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down Expand Up @@ -202,6 +241,7 @@
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = ForgeApp/Info-Debug.plist;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
Expand All @@ -226,6 +266,7 @@
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = ForgeApp/Info-Release.plist;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
Expand Down
28 changes: 28 additions & 0 deletions mobile/ios/App/ForgeApp/AppContentRepositoryFactory.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import Foundation
import ForgeMobile

/// Builds the app's `ContentRepository` using GraphQL endpoint from Info.plist and optional bearer token.
enum AppContentRepositoryFactory {
/// GraphQL endpoint from Info.plist (GraphQLEndpoint). Debug falls back to localhost; Release requires a valid value.
private static var graphQLURL: URL {
let key = "GraphQLEndpoint"
let raw = Bundle.main.object(forInfoDictionaryKey: key) as? String
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines)
if let endpointString = trimmed, !endpointString.isEmpty, let url = URL(string: endpointString) {
return url
}
#if DEBUG
return URL(string: "http://localhost:1337/graphql")!
#else
fatalError("\(key) must be set in Info-Release.plist for production builds.")
#endif
}

static func makeContentRepository() -> ContentRepository {
let token = ProcessInfo.processInfo.environment["STRAPI_FULL_ACCESS_TOKEN"]
.flatMap { $0.isEmpty ? nil : $0 }
?? kStrapiFullAccessToken
let client = GraphQLContentClient(endpoint: graphQLURL, bearerToken: token)
return ContentRepository(client: client)
}
}
2 changes: 1 addition & 1 deletion mobile/ios/App/ForgeApp/ForgeApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import ForgeMobile
struct ForgeApp: App {
var body: some Scene {
WindowGroup {
ForgeRootView()
ForgeRootView(contentRepository: AppContentRepositoryFactory.makeContentRepository())
}
}
}
19 changes: 19 additions & 0 deletions mobile/ios/App/ForgeApp/Info-Debug.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>GraphQLEndpoint</key>
<string>http://localhost:1337/graphql</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSExceptionDomains</key>
<dict>
<key>localhost</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
</dict>
</dict>
</dict>
</plist>
8 changes: 8 additions & 0 deletions mobile/ios/App/ForgeApp/Info-Release.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>GraphQLEndpoint</key>
<string></string>
</dict>
</plist>
48 changes: 48 additions & 0 deletions mobile/ios/GraphQL/Operations/GetWatchExperience.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
query GetWatchExperience(
$locale: I18NLocaleCode!
$filters: ExperienceFiltersInput!
) {
experiences(filters: $filters, locale: $locale) {
documentId
slug
publishedAt
sections {
... on ComponentSectionsMediaCollection {
id
title
subtitle
mediaCollectionDescription: description
categoryLabel
mediaCollectionCtaLink: ctaLink
showItemNumbers
variant
}
... on ComponentSectionsPromoBanner {
id
promoBannerHeading: heading
promoBannerDescription: description
intro
promoBannerCtaLink: ctaLink
}
... on ComponentSectionsInfoBlocks {
id
infoBlocksHeading: heading
intro
infoBlocksDescription: description
blocks {
id
title
description
icon
}
}
... on ComponentSectionsCta {
id
ctaHeading: heading
body
buttonLabel
buttonLink
}
}
}
}
14 changes: 14 additions & 0 deletions mobile/ios/Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 6 additions & 1 deletion mobile/ios/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,15 @@ let package = Package(
products: [
.library(name: "ForgeMobile", targets: ["ForgeMobile"])
],
dependencies: [
.package(url: "https://github.com/apollographql/apollo-ios.git", from: "1.0.0")
],
targets: [
.target(
name: "ForgeMobile",
dependencies: [],
dependencies: [
.product(name: "Apollo", package: "apollo-ios")
],
path: "Sources/ForgeMobile"
)
]
Expand Down
32 changes: 31 additions & 1 deletion mobile/ios/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

Native SwiftUI app. Outside Turborepo graph.

Integrates via ContentClient; implement using `packages/graphql` GraphQL types.
Integrates via **ContentClient**. A GraphQL implementation is provided: **GraphQLContentClient** (endpoint URL + optional bearer token). Construct it with your CMS GraphQL URL (e.g. dev/stage/prod) and pass it to `ContentRepository(client:)`. The app uses **MVVM**: views depend only on **ViewModels**; ViewModels own the repository and expose state and actions (e.g. `WatchHomeViewModel` and `load(locale:)`).

**ForgeMobile library layout** (under `Sources/ForgeMobile/`): **Data/** — `ContentRepository`, `GraphQLContentClient`, `ContentClient`, `MobileContentItem`; **ViewModels/** — screen ViewModels (e.g. `WatchHomeViewModel`); **Views/** — SwiftUI views (e.g. `ForgeRootView`); **Generated/** — Apollo-generated types and operations (do not edit).

## Building and running

Expand All @@ -16,6 +18,17 @@ Integrates via ContentClient; implement using `packages/graphql` GraphQL types.

App source lives under **App/ForgeApp/**; the **ForgeMobile** library is in **Sources/ForgeMobile**. The app target depends on the local ForgeMobile Swift package (one level up from the project); Xcode resolves it automatically when you open the project.

**Local Strapi with API token:**

- **Debug (e.g. run from Cursor/SweetPad):** A build-phase script reads `STRAPI_FULL_ACCESS_TOKEN` from `apps/cms/.env` and attaches it to GraphQL requests. Ensure `apps/cms/.env` has that variable set; no Xcode scheme setup needed.
- **Release:** The script never embeds a token (always `nil`), so the shipped binary stays safe. Use a backend or runtime token for production.

You can still override by setting the **environment variable** `STRAPI_FULL_ACCESS_TOKEN` in the run scheme (Edit Scheme → Run → Environment Variables); the app uses env first, then the generated value.

If you see **"Error: Forbidden access"**, the request is reaching Strapi without a valid token—for Debug from Cursor, add `STRAPI_FULL_ACCESS_TOKEN` to `apps/cms/.env` and rebuild.

**Info plists and App Store:** **Debug** builds use `Info-Debug.plist` (allows HTTP to localhost for local Strapi). **Release** builds (including **Archive** for App Store Connect) use `Info-Release.plist`, which has no ATS exception, so the shipped app is ATS-clean for review.

### Command line

From this directory (`mobile/ios`). Requires **Xcode 16+** (Swift 6). Point `xcodebuild` at the app project in `App/`:
Expand All @@ -30,3 +43,20 @@ xcodebuild -project App/ForgeApp.xcodeproj -scheme ForgeApp -destination 'platfo
# Build for a generic iOS Simulator destination
xcodebuild -project App/ForgeApp.xcodeproj -scheme ForgeApp -destination 'generic/platform=iOS Simulator' build
```

## GraphQL and codegen

The **ForgeMobile** package uses [Apollo iOS](https://www.apollographql.com/docs/ios/) for the CMS GraphQL client. Schema and operations:

- **Schema**: `apps/cms/schema.graphql` (repo root).
- **Operations**: `GraphQL/Operations/*.graphql` (e.g. `GetWatchExperience.graphql`).
- **Generated Swift**: `Sources/ForgeMobile/Generated/` (do not edit by hand).

To regenerate after schema or operation changes, **run from `mobile/ios`** (required: the schema path in config is relative and is resolved from the current working directory):

1. Install the Apollo CLI (once):
`swift package --allow-writing-to-package-directory --allow-network-connections all apollo-cli-install`
2. Generate:
`./apollo-ios-cli generate -p apollo-codegen-configuration.json`

Config: `apollo-codegen-configuration.json` (embedded in ForgeMobile target; `schemaSearchPaths` uses `../../apps/cms/schema.graphql`). Any CI or script that runs codegen must use `mobile/ios` as the working directory.
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,6 @@ public final class ContentRepository {
}

public func fetchHome(locale: String) async throws -> MobileContentItem? {
try await client.getContent(locale: locale, slug: "home")
try await client.getContent(locale: locale, slug: "experience")
}
Comment thread
Ur-imazing marked this conversation as resolved.
}
Loading
Loading