AI
AI Image Generation
ShipThatApp ships with a ready-to-use DALL·E image generation feature. The user provides a prompt, the app sends a request to the secure backend proxy, and DALL·E returns a hosted image URL that the app loads, displays, saves, or shares. Every request is HMAC-signed and routed through the proxy, so your OpenAI key never ships inside the app.
Overview
The feature follows the project's MVVM pattern with the @Observable macro:
GenImageView— the SwiftUI screen. Triggers generation, shows a loading overlay, and presents the result in a sheet.DALLEViewModel— a@MainActor @Observableview model that drives state (imageUrl,isLoading,isFinished,errorMessage) and calls the service.DALLEService— a thin service that builds the request and hands it toApiClient.DALLERequest/DALLEResponse— theCodablerequest payload and the decoded response.ApiClient— the shared, HMAC-signing HTTP client that talks to the backend proxy.
Request and Response Models
DALLERequest
DALLERequest is the payload sent to the proxy. It mirrors the parameters DALL·E expects, but every option has a sensible default so you can build a request from just a prompt.
struct DALLERequest: Codable {
let prompt: String
let model: String?
let n: Int?
let quality: String?
let response_format: String?
let size: String?
let style: String?
init(
prompt: String,
model: String? = "dall-e-3",
n: Int? = 1,
quality: String? = "hd",
response_format: String? = "url",
size: String? = "1024x1024",
style: String? = "natural"
) {
self.prompt = prompt
self.model = model
self.n = n
self.quality = quality
self.response_format = response_format
self.size = size
self.style = style
}
}
Because the defaults cover the common case, building a request is a one-liner:
let request = DALLERequest(prompt: "Generate an iOS icon")
Override any default when you need a different model, size, or style:
let request = DALLERequest(
prompt: "A minimalist mountain logo",
size: "1792x1024",
style: "vivid"
)
DALLEResponse
The proxy returns a single hosted image URL, which decodes into a small struct:
struct DALLEResponse: Decodable {
let url: String
}
The Generation Flow
1. The View triggers generation
GenImageView owns the view model as @State and kicks off a request when the user taps the button. While isLoading is true, the button is disabled and a circular ProgressView overlay is shown. When isFinished flips to true, a sheet presents the result.
struct GenImageView: View {
@State private var viewModel = DALLEViewModel()
var body: some View {
VStack {
// ...
Button(action: generateIcon) {
Text("Generate iOS Icon")
}
.disabled(viewModel.isLoading)
}
.sheet(isPresented: $viewModel.isFinished) {
if let imageUrl = viewModel.imageUrl {
IconView(imageUrl: imageUrl)
} else {
Text("Failed to generate image")
}
}
}
private func generateIcon() {
let request = DALLERequest(prompt: "Generate an iOS icon")
viewModel.generateImage(request: request)
}
}
2. The ViewModel manages state
DALLEViewModel is annotated @MainActor and @Observable. Because the type is main-actor isolated, the Task it spawns inherits that isolation, so every state mutation already runs on the main actor — no manual dispatching needed. On success it stores the returned url; on failure it captures the error message.
@MainActor
@Observable
final class DALLEViewModel {
var imageUrl: String?
var isLoading = false
var errorMessage: String?
var isFinished = false
@ObservationIgnored
private let dalleService = DALLEService()
func generateImage(request: DALLERequest) {
isLoading = true
isFinished = false
Task {
do {
let response = try await dalleService.generateImage(request: request)
imageUrl = response.url
} catch {
errorMessage = error.localizedDescription
}
isLoading = false
isFinished = true
}
}
}
3. The Service calls the secure proxy
DALLEService encodes the request and sends it to the dalle endpoint through the shared ApiClient. It unwraps the Result, returning the decoded DALLEResponse on success and throwing on failure.
class DALLEService {
func generateImage(request: DALLERequest) async throws -> DALLEResponse {
let result = try await ApiClient.shared.sendRequest(
endpoint: Endpoints.dalle,
body: JSONEncoder().encode(request),
responseModel: DALLEResponse.self
)
switch result {
case .success(let response):
return response
case .failure(let failure):
throw failure
}
}
}
The Secure Backend Proxy
DALL·E requests never hit OpenAI directly. They are POSTed to the dalle path on your backend (Config.Api.baseURL), which holds the OpenAI credentials and forwards the call. This keeps your API key off the device entirely.
ApiClient signs every outgoing request with HMAC before it leaves the app. The signature covers a canonical string built from the HTTP method, path, timestamp, and a SHA-256 hash of the body:
let canonical = "\(method)\n\(path)\n\(timestamp)\n\(bodyHash)"
let signature = CryptoUtils.shared.createHmac(key: signingKey, phrase: canonical)
request.setValue(signature, forHTTPHeaderField: "x-signature")
request.setValue(timestamp, forHTTPHeaderField: "x-timestamp")
The backend recomputes the identical string and verifies the signature, so a captured request cannot be tampered with or replayed. The signing key for the dalle endpoint is the per-session token returned by /auth_token and stored in the Keychain — not a long-lived secret baked into the binary.
Heads Up!
Your OpenAI key lives only on the backend. The app authenticates to your proxy with a short-lived, Keychain-stored session token. See the API Client docs for the full signing and retry flow.
Presenting the Result
IconView receives the imageUrl string and loads it with AsyncImage. On iOS 27 it uses the URLRequest-based initializer with a .returnCacheDataElseLoad cache policy, so re-presenting the sheet reuses the already-downloaded image instead of refetching it; on earlier systems it falls back to the standard url: initializer.
if #available(iOS 27, *) {
AsyncImage(
request: URLRequest(url: url, cachePolicy: .returnCacheDataElseLoad),
content: imageView
)
} else {
AsyncImage(url: url, content: imageView)
}
Once the image loads, IconView renders it to a UIImage with ImageRenderer so the user can:
- Save to Photos via
UIImageWriteToSavedPhotosAlbum. - Share the image with a SwiftUI
ShareLink.
If a phase fails to load, the view shows a "Failed to load image" message and hides the save/share actions.
Customizing the Feature
The bundled GenImageView hard-codes the prompt "Generate an iOS icon" as a demo. To make it your own:
- Collect a prompt from the user. Add a
TextFieldbound to a@Statestring and pass it intoDALLERequest(prompt:)instead of the fixed string. - Tune the output. Override
model,size,quality, orstyleonDALLERequestfor different aspect ratios or aesthetics. - Surface errors.
DALLEViewModel.errorMessageis populated on failure — display it with aToastso users get clear feedback.
Generation takes time
DALL·E generation is not instant. The dalle endpoint can take several seconds, which is why Config.Api.requestTimeout is generous. Keep the isLoading overlay in place so users know work is in progress.