Quick Start
Copy
mizu new ./my-ios-app --template mobile/ios
- Swift + SwiftUI architecture
- Pre-configured Mizu API client
- Authentication flow
- Offline support foundation
- Push notification setup
Project Structure
Copy
my-ios-app/
├── backend/ # Mizu Go backend
│ ├── cmd/server/
│ │ └── main.go
│ ├── app/server/
│ │ ├── app.go
│ │ ├── config.go
│ │ └── routes.go
│ ├── go.mod
│ └── Makefile
│
├── ios/ # iOS app
│ ├── App/
│ │ ├── MyApp.swift # App entry point
│ │ ├── ContentView.swift
│ │ └── AppDelegate.swift
│ ├── Core/
│ │ ├── API/
│ │ │ ├── APIClient.swift
│ │ │ ├── APIError.swift
│ │ │ └── Endpoints.swift
│ │ ├── Auth/
│ │ │ ├── AuthManager.swift
│ │ │ └── KeychainHelper.swift
│ │ └── Storage/
│ │ └── UserDefaults+Extensions.swift
│ ├── Features/
│ │ ├── Home/
│ │ ├── Profile/
│ │ └── Settings/
│ ├── Shared/
│ │ ├── Components/
│ │ ├── Extensions/
│ │ └── Modifiers/
│ ├── Resources/
│ │ └── Assets.xcassets
│ └── MyApp.xcodeproj
│
└── Makefile
Template Options
Copy
mizu new ./my-app --template mobile/ios \
--var name=MyApp \
--var bundleId=com.example.myapp \
--var minIOS=16.0 \
--var ui=swiftui
| Variable | Description | Default |
|---|---|---|
name | Project name | Directory name |
bundleId | Bundle identifier | com.example.{{name}} |
minIOS | Minimum iOS version | 16.0 |
ui | UI framework: swiftui, uikit, hybrid | swiftui |
API Client
The template includes a pre-configured API client:Copy
// Core/API/APIClient.swift
class APIClient {
static let shared = APIClient()
private let baseURL: URL
private let session: URLSession
private let deviceID: String
init() {
self.baseURL = URL(string: Configuration.apiBaseURL)!
self.deviceID = DeviceInfo.uniqueID
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 30
config.httpAdditionalHeaders = [
"Content-Type": "application/json",
"Accept": "application/json",
]
self.session = URLSession(configuration: config)
}
func request<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
var request = URLRequest(url: baseURL.appendingPathComponent(endpoint.path))
request.httpMethod = endpoint.method.rawValue
// Add mobile headers
request.addMizuHeaders(deviceID: deviceID)
// Add auth if available
if let token = AuthManager.shared.accessToken {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
// Add body
if let body = endpoint.body {
request.httpBody = try JSONEncoder().encode(body)
}
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw APIError.invalidResponse
}
// Handle errors
if httpResponse.statusCode >= 400 {
let error = try JSONDecoder().decode(APIErrorResponse.self, from: data)
throw APIError.server(error, statusCode: httpResponse.statusCode)
}
// Check deprecation warning
if httpResponse.value(forHTTPHeaderField: "X-API-Deprecated") == "true" {
NotificationCenter.default.post(name: .apiDeprecated, object: nil)
}
return try JSONDecoder().decode(T.self, from: data)
}
}
// Add Mizu headers
extension URLRequest {
mutating func addMizuHeaders(deviceID: String) {
setValue(deviceID, forHTTPHeaderField: "X-Device-ID")
setValue(Bundle.main.appVersion, forHTTPHeaderField: "X-App-Version")
setValue(Bundle.main.buildNumber, forHTTPHeaderField: "X-App-Build")
setValue(UIDevice.current.modelIdentifier, forHTTPHeaderField: "X-Device-Model")
setValue("ios", forHTTPHeaderField: "X-Platform")
setValue(UIDevice.current.systemVersion, forHTTPHeaderField: "X-OS-Version")
setValue(TimeZone.current.identifier, forHTTPHeaderField: "X-Timezone")
setValue(Locale.current.identifier, forHTTPHeaderField: "X-Locale")
setValue("v2", forHTTPHeaderField: "X-API-Version")
}
}
Authentication
Copy
// Core/Auth/AuthManager.swift
@MainActor
class AuthManager: ObservableObject {
static let shared = AuthManager()
@Published var isAuthenticated = false
@Published var currentUser: User?
private let keychain = KeychainHelper.shared
var accessToken: String? {
keychain.read(key: "access_token")
}
func login(email: String, password: String) async throws {
let response: AuthResponse = try await APIClient.shared.request(
.login(email: email, password: password)
)
keychain.save(response.accessToken, key: "access_token")
keychain.save(response.refreshToken, key: "refresh_token")
currentUser = response.user
isAuthenticated = true
}
func logout() {
keychain.delete(key: "access_token")
keychain.delete(key: "refresh_token")
currentUser = nil
isAuthenticated = false
}
func refreshTokenIfNeeded() async throws {
guard let refreshToken = keychain.read(key: "refresh_token") else {
throw AuthError.notAuthenticated
}
let response: RefreshResponse = try await APIClient.shared.request(
.refreshToken(token: refreshToken)
)
keychain.save(response.accessToken, key: "access_token")
}
}
SwiftUI Integration
App Entry Point
Copy
// App/MyApp.swift
@main
struct MyApp: App {
@StateObject private var authManager = AuthManager.shared
@StateObject private var appState = AppState()
init() {
// Configure appearance
configureAppearance()
// Check for updates
Task {
await AppUpdateManager.shared.checkForUpdates()
}
}
var body: some Scene {
WindowGroup {
Group {
if authManager.isAuthenticated {
MainTabView()
} else {
LoginView()
}
}
.environmentObject(authManager)
.environmentObject(appState)
}
}
}
View Model Pattern
Copy
// Features/Home/HomeViewModel.swift
@MainActor
class HomeViewModel: ObservableObject {
@Published var items: [Item] = []
@Published var isLoading = false
@Published var error: Error?
func loadItems() async {
isLoading = true
error = nil
do {
let response: PageResponse<Item> = try await APIClient.shared.request(.getItems())
items = response.data
} catch {
self.error = error
}
isLoading = false
}
}
// Features/Home/HomeView.swift
struct HomeView: View {
@StateObject private var viewModel = HomeViewModel()
var body: some View {
NavigationStack {
Group {
if viewModel.isLoading {
ProgressView()
} else if let error = viewModel.error {
ErrorView(error: error, retry: { Task { await viewModel.loadItems() } })
} else {
List(viewModel.items) { item in
ItemRow(item: item)
}
.refreshable {
await viewModel.loadItems()
}
}
}
.navigationTitle("Home")
}
.task {
await viewModel.loadItems()
}
}
}
Push Notifications
Copy
// Core/Push/PushManager.swift
class PushManager: NSObject, ObservableObject, UNUserNotificationCenterDelegate {
static let shared = PushManager()
@Published var isRegistered = false
override init() {
super.init()
UNUserNotificationCenter.current().delegate = self
}
func requestPermission() async -> Bool {
do {
let granted = try await UNUserNotificationCenter.current()
.requestAuthorization(options: [.alert, .badge, .sound])
if granted {
await MainActor.run {
UIApplication.shared.registerForRemoteNotifications()
}
}
return granted
} catch {
return false
}
}
func registerToken(_ deviceToken: Data) async {
let token = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
do {
try await APIClient.shared.request(
.registerPushToken(token: token, sandbox: isDebugBuild)
) as EmptyResponse
isRegistered = true
} catch {
print("Failed to register push token: \(error)")
}
}
// Handle foreground notifications
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification
) async -> UNNotificationPresentationOptions {
return [.banner, .badge, .sound]
}
// Handle notification tap
func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse
) async {
let userInfo = response.notification.request.content.userInfo
handleNotificationData(userInfo)
}
private func handleNotificationData(_ userInfo: [AnyHashable: Any]) {
// Handle deep link or action
if let action = userInfo["action"] as? String {
NotificationCenter.default.post(
name: .handlePushAction,
object: nil,
userInfo: ["action": action]
)
}
}
}
Deep Links
Copy
// App/AppDelegate.swift
class AppDelegate: NSObject, UIApplicationDelegate {
func application(
_ application: UIApplication,
continue userActivity: NSUserActivity
) -> Bool {
guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
let url = userActivity.webpageURL else {
return false
}
return DeepLinkHandler.shared.handle(url)
}
}
// Core/DeepLink/DeepLinkHandler.swift
class DeepLinkHandler: ObservableObject {
static let shared = DeepLinkHandler()
@Published var pendingDeepLink: DeepLink?
func handle(_ url: URL) -> Bool {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
return false
}
let deepLink = parseDeepLink(from: components)
pendingDeepLink = deepLink
return deepLink != nil
}
private func parseDeepLink(from components: URLComponents) -> DeepLink? {
let path = components.path
if path.hasPrefix("/share/") {
let id = String(path.dropFirst("/share/".count))
return .share(id: id)
}
if path.hasPrefix("/profile/") {
let username = String(path.dropFirst("/profile/".count))
return .profile(username: username)
}
return nil
}
}
enum DeepLink: Equatable {
case share(id: String)
case profile(username: String)
case settings
}
Offline Support
Copy
// Core/Sync/SyncManager.swift
class SyncManager: ObservableObject {
static let shared = SyncManager()
@Published var isSyncing = false
@Published var lastSyncTime: Date?
private var syncToken: String? {
get { UserDefaults.standard.string(forKey: "sync_token") }
set { UserDefaults.standard.set(newValue, forKey: "sync_token") }
}
func sync() async throws {
guard !isSyncing else { return }
isSyncing = true
defer { isSyncing = false }
let endpoint: Endpoint = syncToken.map { .sync(token: $0) } ?? .sync(token: nil)
let response: SyncResponse<Item> = try await APIClient.shared.request(endpoint)
// Apply changes to local database
try await LocalDatabase.shared.apply(delta: response)
// Store new token
syncToken = response.syncToken
lastSyncTime = Date()
}
}
Running the Project
Start Backend
Copy
cd backend
make run
# Server running at http://localhost:3000
Run iOS App
- Open
ios/MyApp.xcodeprojin Xcode - Select your target device or simulator
- Press Cmd+R to build and run
Development Tips
Copy
// Configuration.swift
enum Configuration {
#if DEBUG
static let apiBaseURL = "http://localhost:3000"
#else
static let apiBaseURL = "https://api.example.com"
#endif
}
Best Practices
Error Handling
Copy
func loadData() async {
do {
let data: Response = try await APIClient.shared.request(.getData())
// Handle success
} catch APIError.server(let error, let statusCode) {
switch error.code {
case "unauthorized":
await AuthManager.shared.logout()
case "upgrade_required":
showUpdateAlert(storeURL: error.details?["store_url"])
default:
showError(error.message)
}
} catch {
showError("Something went wrong")
}
}
Combine with SwiftUI
Copy
// Use @Published for reactive updates
class ViewModel: ObservableObject {
@Published var state: ViewState = .idle
enum ViewState {
case idle
case loading
case loaded([Item])
case error(Error)
}
}
Testing
Copy
// Mock API client for tests
protocol APIClientProtocol {
func request<T: Decodable>(_ endpoint: Endpoint) async throws -> T
}
class MockAPIClient: APIClientProtocol {
var mockResponse: Any?
var mockError: Error?
func request<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
if let error = mockError { throw error }
return mockResponse as! T
}
}