cross-platform
Cross-Platform: macOS ↔ iOS
Patterns for sharing code between macOS and iOS, building iOS-specific extensions, and syncing data across platforms.
Critical Constraints
- ❌ Never import
AppKitorUIKitin shared code — use SwiftUI types and#if os()for platform-specific imports - ❌ Never use
NSColor/UIColordirectly in shared views — use SwiftUIColor - ❌ Never use
NSFont/UIFontdirectly — use SwiftUI.font() - ✅ Abstract platform services behind protocols
- ✅ Use
#if os(macOS)/#if os(iOS)for platform-specific implementations - ✅ Use
@Environment(\.horizontalSizeClass)for adaptive layouts within a platform
Project Structure
MyApp/
├── Shared/ # 70-80% of code
│ ├── Models/ # SwiftData models
│ ├── ViewModels/ # @Observable view models
│ ├── Services/
│ │ ├── StorageService.swift
│ │ ├── SyncService.swift
│ │ └── ClipboardService.swift # Protocol — platform-abstracted
│ ├── Views/
│ │ ├── Components/ # Shared UI: cards, rows, badges
│ │ └── Screens/ # Platform-adapted via #if os()
│ └── Extensions/
├── macOS/ # 15-20% — Mac-specific
│ ├── App/
│ │ ├── MacApp.swift
│ │ └── AppDelegate.swift
│ ├── Services/
│ │ ├── HotkeyManager.swift
│ │ ├── MenuBarController.swift
│ │ └── MacClipboardService.swift
│ └── Views/
│ ├── FloatingPanel.swift
│ └── QuickAccessView.swift
├── iOS/ # 15-20% — iOS-specific
│ ├── App/
│ │ └── iOSApp.swift
│ ├── Services/
│ │ ├── KeyboardExtension/
│ │ └── iOSClipboardService.swift
│ └── Views/
│ ├── MainTabView.swift
│ └── WidgetView.swift
├── Widgets/ # Shared widget target
└── MyApp.xcodeproj
Platform Abstraction
Protocol-Based Services
// Shared/Services/ClipboardServiceProtocol.swift
protocol ClipboardServiceProtocol {
func copy(_ text: String)
func read() -> String?
}
// macOS/Services/MacClipboardService.swift
#if os(macOS)
import AppKit
class MacClipboardService: ClipboardServiceProtocol {
func copy(_ text: String) {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(text, forType: .string)
}
func read() -> String? { NSPasteboard.general.string(forType: .string) }
}
#endif
// iOS/Services/iOSClipboardService.swift
#if os(iOS)
import UIKit
class iOSClipboardService: ClipboardServiceProtocol {
func copy(_ text: String) { UIPasteboard.general.string = text }
func read() -> String? { UIPasteboard.general.string }
}
#endif
// Shared/Services/ClipboardService.swift
class ClipboardService {
static var shared: ClipboardServiceProtocol = {
#if os(macOS)
return MacClipboardService()
#else
return iOSClipboardService()
#endif
}()
}
Conditional Compilation in Views
struct PromptListView: View {
var body: some View {
#if os(macOS)
NavigationSplitView {
sidebar
} detail: {
detail
}
#else
NavigationStack {
list
}
#endif
}
}
Environment-Based Adaptation (iPad vs iPhone)
struct AdaptiveView: View {
@Environment(\.horizontalSizeClass) var sizeClass
var body: some View {
if sizeClass == .compact {
VStack { content } // iPhone
} else {
HStack { content } // iPad / Mac
}
}
}
Shared Components with Platform Styling
struct GlassCard<Content: View>: View {
let content: Content
init(@ViewBuilder content: () -> Content) { self.content = content() }
var body: some View {
content
.padding()
.glassEffect(.regular, in: .rect(cornerRadius: 16))
}
}
struct PrimaryButton: View {
let title: String
let action: () -> Void
var body: some View {
Button(title, action: action)
.buttonStyle(.glassProminent)
#if os(macOS)
.controlSize(.large)
#endif
}
}
iOS Extensions
Custom Keyboard Extension
Replaces global hotkey on iOS — users type prompts via custom keyboard.
// iOS/KeyboardExtension/KeyboardViewController.swift
import UIKit
import SwiftUI
class KeyboardViewController: UIInputViewController {
override func viewDidLoad() {
super.viewDidLoad()
let hostingController = UIHostingController(rootView: KeyboardView(
onSelect: { [weak self] prompt in
self?.textDocumentProxy.insertText(prompt.content)
}
))
addChild(hostingController)
view.addSubview(hostingController.view)
hostingController.view.frame = view.bounds
hostingController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
}
}
Interactive Widgets (Home Screen + Lock Screen)
import WidgetKit
import SwiftUI
import AppIntents
struct PromptWidget: Widget {
let kind = "PromptWidget"
var body: some WidgetConfiguration {
AppIntentConfiguration(
kind: kind,
intent: SelectPromptsIntent.self,
provider: PromptTimelineProvider()
) { entry in
PromptWidgetView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
}
.configurationDisplayName("Quick Prompts")
.description("Tap to copy your favorite prompts")
.supportedFamilies([.systemSmall, .systemMedium, .accessoryRectangular])
}
}
// Widget buttons trigger App Intents directly
struct PromptWidgetView: View {
let entry: PromptEntry
var body: some View {
VStack(alignment: .leading, spacing: 8) {
ForEach(entry.prompts) { prompt in
Button(intent: CopyPromptIntent(prompt: prompt.entity)) {
HStack {
Image(systemName: prompt.icon).foregroundStyle(.secondary)
Text(prompt.title).lineLimit(1)
Spacer()
Image(systemName: "doc.on.clipboard").font(.caption).foregroundStyle(.tertiary)
}
.padding(.vertical, 4)
}
.buttonStyle(.plain)
}
}
.padding()
}
}
Share Extension (Save text from other apps)
import UIKit
import SwiftUI
import UniformTypeIdentifiers
class ShareViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
guard let item = extensionContext?.inputItems.first as? NSExtensionItem,
let provider = item.attachments?.first(where: {
$0.hasItemConformingToTypeIdentifier(UTType.plainText.identifier)
}) else { close(); return }
provider.loadItem(forTypeIdentifier: UTType.plainText.identifier) { [weak self] text, _ in
DispatchQueue.main.async {
if let text = text as? String { self?.showSaveUI(text: text) }
else { self?.close() }
}
}
}
func showSaveUI(text: String) {
let saveView = SavePromptView(initialContent: text,
onSave: { [weak self] title, content, category in
SharedPromptStore.shared.add(SharedPrompt(title: title, content: content, category: category))
self?.close()
},
onCancel: { [weak self] in self?.close() }
)
let hc = UIHostingController(rootView: saveView)
addChild(hc)
view.addSubview(hc.view)
hc.view.frame = view.bounds
hc.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
}
func close() { extensionContext?.completeRequest(returningItems: nil) }
}
Control Center Widget (iOS 18+)
import WidgetKit
struct PromptControlWidget: ControlWidget {
var body: some ControlWidgetConfiguration {
AppIntentControlConfiguration(kind: "PromptControl", intent: CopyFavoritePromptIntent.self) { config in
ControlWidgetButton(action: config) {
Label(config.prompt?.title ?? "Prompt", systemImage: "doc.on.clipboard")
}
}
.displayName("Quick Prompt")
}
}
URL Scheme Deep Links
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.onOpenURL { url in
// myapp://copy/{id}, myapp://edit/{id}, myapp://new?content={encoded}
guard url.scheme == "myapp" else { return }
switch url.host {
case "copy":
if let id = UUID(uuidString: url.lastPathComponent) {
PromptService.shared.copyToClipboard(id: id)
}
case "edit":
if let id = UUID(uuidString: url.lastPathComponent) {
NavigationState.shared.editPrompt(id: id)
}
default: break
}
}
}
}
}
Data Sync & App Groups
App Groups for Extensions/Widgets
Extensions (widgets, keyboard, share) run in separate processes. Share data via App Groups.
// 1. Add App Groups capability to main app AND all extensions
// 2. Use same group identifier: "group.com.yourapp.shared"
// Shared container for SwiftData
extension ModelContainer {
static var shared: ModelContainer = {
let schema = Schema([Prompt.self, Category.self])
let storeURL = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: "group.com.yourapp.shared")!
.appendingPathComponent("prompts.sqlite")
let config = ModelConfiguration(url: storeURL)
return try! ModelContainer(for: schema, configurations: [config])
}()
}
// Lightweight sharing via UserDefaults
let sharedDefaults = UserDefaults(suiteName: "group.com.yourapp.shared")
sharedDefaults?.set(encodedData, forKey: "prompts")
CloudKit Sync with SwiftData
let config = ModelConfiguration(
schema: schema,
isStoredInMemoryOnly: false,
cloudKitDatabase: .automatic // Enable iCloud sync
)
Sync Status Monitoring
class SyncMonitor: ObservableObject {
@Published var syncStatus: SyncStatus = .unknown
enum SyncStatus { case unknown, syncing, synced, error(String), noAccount }
init() {
CKContainer.default().accountStatus { [weak self] status, _ in
DispatchQueue.main.async {
switch status {
case .available: self?.syncStatus = .synced
case .noAccount: self?.syncStatus = .noAccount
default: self?.syncStatus = .error("iCloud unavailable")
}
}
}
// Observe sync events
NotificationCenter.default.addObserver(
forName: NSPersistentCloudKitContainer.eventChangedNotification,
object: nil, queue: .main
) { [weak self] notification in
guard let event = notification.userInfo?[NSPersistentCloudKitContainer.eventNotificationUserInfoKey]
as? NSPersistentCloudKitContainer.Event else { return }
self?.syncStatus = event.endDate != nil ? .synced : .syncing
}
}
}
JSON Export/Import
struct PromptExport: Codable {
let version: Int
let exportedAt: Date
let prompts: [PromptData]
struct PromptData: Codable {
let id: UUID, title: String, content: String
let categoryName: String?, isFavorite: Bool
}
}
extension PromptService {
func exportToJSON() throws -> Data {
let prompts = try context.fetch(FetchDescriptor<Prompt>())
let export = PromptExport(version: 1, exportedAt: Date(), prompts: prompts.map {
.init(id: $0.id, title: $0.title, content: $0.content,
categoryName: $0.category?.name, isFavorite: $0.isFavorite)
})
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
return try encoder.encode(export)
}
func importFromJSON(_ data: Data) throws -> Int {
let export = try JSONDecoder().decode(PromptExport.self, from: data)
var imported = 0
for p in export.prompts {
let existing = try? context.fetch(
FetchDescriptor<Prompt>(predicate: #Predicate { $0.id == p.id })
).first
if existing == nil {
let prompt = Prompt(title: p.title, content: p.content)
prompt.id = p.id; prompt.isFavorite = p.isFavorite
context.insert(prompt); imported += 1
}
}
try context.save()
return imported
}
}
Schema Versioning
enum SchemaV1: VersionedSchema {
static var versionIdentifier = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] { [Prompt.self, Category.self] }
}
enum SchemaV2: VersionedSchema {
static var versionIdentifier = Schema.Version(2, 0, 0)
static var models: [any PersistentModel.Type] { [Prompt.self, Category.self, PromptVariable.self] }
}
enum MigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] { [SchemaV1.self, SchemaV2.self] }
static var stages: [MigrationStage] {
[MigrationStage.lightweight(fromVersion: SchemaV1.self, toVersion: SchemaV2.self)]
}
}
Widget Refresh
// After any prompt change — notify widgets
WidgetCenter.shared.reloadAllTimelines()
// Or specific widget
WidgetCenter.shared.reloadTimelines(ofKind: "PromptWidget")
macOS → iOS Migration Checklist
Core Functionality
- All models work on both platforms (no AppKit/UIKit imports)
- ViewModels have no platform-specific imports
- Services use protocol abstraction
UI Adaptation
- Navigation adapted (SplitView → Stack on iPhone)
- Touch targets ≥ 44pt minimum
- No hover-only interactions (add tap alternatives)
- Keyboard shortcuts have touch equivalents
Platform Features
- Hotkey → Keyboard extension or widget on iOS
- Menu bar → App icon or widget on iOS
- Floating panel → Sheet or full-screen modal on iOS
- Right-click → Long press context menu
Data
- CloudKit sync enabled
- App Groups configured for extensions
- Shared UserDefaults for lightweight data
- SwiftData shared container for extensions
Testing
- Shared tests pass on both platforms
- UI tests for each platform
- Widget previews work
- Test on real device (not just simulator)
Common Pitfalls
- Documents directory differs — abstract file paths, don't hardcode
- Keyboard presence on iOS — handle
keyboardLayoutGuideor.ignoresSafeArea(.keyboard) - Right-click menus — provide
.contextMenu(works as right-click on Mac, long-press on iOS) - Window management — macOS has multiple windows; iOS is single-window (use
openWindowconditionally) - Status bar — macOS
MenuBarExtra; no equivalent on iOS (use widget instead)
More from makgunay/claude-swift-skills
swiftui-core
Core SwiftUI patterns for macOS and iOS development including navigation (NavigationSplitView, NavigationStack), state management (@State, @Binding, @Environment, @Bindable with @Observable), the new customizable toolbar system (toolbar IDs, ToolbarSpacer, DefaultToolbarItem, searchToolbarBehavior, matchedTransitionSource, sharedBackgroundVisibility), styled text editing (TextEditor with AttributedString, AttributedTextSelection, transformAttributes, textFormattingDefinition), and layout patterns. Use when building any SwiftUI view, implementing navigation, managing state, creating toolbars, or building rich text editors. Corrects common LLM errors like using deprecated NavigationView, wrong state wrappers, and outdated toolbar APIs.
14global-hotkeys
System-wide keyboard shortcut registration on macOS using NSEvent monitoring (simple, app-level) and Carbon EventHotKey API (reliable, system-wide). Covers NSEvent.addGlobalMonitorForEvents and addLocalMonitorForEvents, CGEvent tap for keystroke simulation, Carbon RegisterEventHotKey for system-wide hotkeys, modifier flag handling (.deviceIndependentFlagsMask), common key code mappings, debouncing, Accessibility permission requirements (AXIsProcessTrusted), and SwiftUI .onKeyPress for in-app shortcuts. Use when implementing global keyboard shortcuts, hotkey-triggered panels, or system-wide key event monitoring.
11swiftui-webkit
Native SwiftUI WebKit integration with the new WebView struct and WebPage observable class. Covers WebView creation from URLs, WebPage for navigation control and state management, JavaScript execution (callJavaScript with arguments and content worlds), custom URL scheme handlers, navigation management (load, reload, back/forward), navigation decisions, text search (findNavigator), content capture (snapshots, PDF generation, web archives), and configuration (data stores, user agents, JS permissions). Use when embedding web content in SwiftUI apps instead of the old WKWebView + UIViewRepresentable/NSViewRepresentable bridge pattern. This is a brand new API — do NOT use the old WKWebView wrapping approach.
10liquid-glass
Comprehensive guide to Apple's Liquid Glass design system introduced in macOS 26, iOS 26, and across all Apple platforms. Covers SwiftUI (.glassEffect(), GlassEffectContainer, .interactive(), .tint(), glassEffectID morphing, .buttonStyle(.glass), .buttonStyle(.glassProminent), glassEffectUnion), AppKit (NSGlassEffectView, NSGlassEffectContainerView), UIKit (UIGlassEffect, UIGlassContainerEffect, UIScrollEdgeEffect), and WidgetKit (accented rendering mode, widgetAccentable). Use whenever building UI with the new Apple design language, adopting glass effects, styling buttons or toolbars, or creating modern macOS/iOS interfaces. Always consult this skill when asked about new Apple design.
10tech-stack-validator
Validates and recommends technology stacks for native macOS/iOS app projects against PRD and architecture requirements. Reads the PRD and architecture documents (or gathers requirements interactively), then systematically checks every technology choice for: OS version availability, framework capability gaps, performance feasibility, distribution compatibility (sandbox vs direct), API deprecation risks, dependency conflicts, and timeline realism for the team size. Produces a structured validation report with go/no-go verdicts, risk flags, and alternative recommendations. Use when user says things like 'validate my tech stack', 'check if this architecture works', 'what should I build this with', 'is SwiftData the right choice', 'can I ship this on the App Store', 'review my architecture', or before starting implementation of any PRD. Also use when migrating between tech stacks or evaluating whether to adopt a new framework.
9testing-swift
Swift Testing framework (@Test, #expect, @Suite) for writing tests in Swift. Covers the modern @Test attribute replacing XCTestCase func test* methods, #expect macro replacing XCTAssert*, @Suite for test organization, parameterized tests with @Test(arguments:), test traits (.disabled, .bug, .timeLimit, .tags), testing @Observable models, testing async code with structured concurrency, confirmation API for events/callbacks, and migration from XCTest. Use when writing unit tests, integration tests, or migrating from XCTest to Swift Testing.
8