cross-platform
4
总安装量
3
周安装量
#49182
全站排名
安装命令
npx skills add https://github.com/makgunay/claude-swift-skills --skill cross-platform
Agent 安装分布
opencode
3
claude-code
3
codex
3
cursor
3
gemini-cli
2
github-copilot
2
Skill 文档
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)