cross-platform

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Cross-Platform: macOS ↔ iOS

跨平台开发:macOS ↔ iOS

Patterns for sharing code between macOS and iOS, building iOS-specific extensions, and syncing data across platforms.
本文介绍macOS与iOS之间的代码共享模式、iOS专属扩展的构建方法,以及跨平台的数据同步方案。

Critical Constraints

关键约束

  • ❌ Never import
    AppKit
    or
    UIKit
    in shared code — use SwiftUI types and
    #if os()
    for platform-specific imports
  • ❌ Never use
    NSColor
    /
    UIColor
    directly in shared views — use SwiftUI
    Color
  • ❌ Never use
    NSFont
    /
    UIFont
    directly — 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
  • ❌ 永远不要在共享代码中导入
    AppKit
    UIKit
    ——使用SwiftUI类型和
    #if os()
    进行平台专属导入
  • ❌ 永远不要在共享视图中直接使用
    NSColor
    /
    UIColor
    ——使用SwiftUI的
    Color
  • ❌ 永远不要直接使用
    NSFont
    /
    UIFont
    ——使用SwiftUI的
    .font()
  • ✅ 通过协议抽象平台服务
  • ✅ 使用
    #if os(macOS)
    /
    #if os(iOS)
    编写平台专属实现
  • ✅ 使用
    @Environment(\.horizontalSizeClass)
    实现平台内的自适应布局

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
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

基于协议的服务

swift
// 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
    }()
}
swift
// 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

视图中的条件编译

swift
struct PromptListView: View {
    var body: some View {
        #if os(macOS)
        NavigationSplitView {
            sidebar
        } detail: {
            detail
        }
        #else
        NavigationStack {
            list
        }
        #endif
    }
}
swift
struct PromptListView: View {
    var body: some View {
        #if os(macOS)
        NavigationSplitView {
            sidebar
        } detail: {
            detail
        }
        #else
        NavigationStack {
            list
        }
        #endif
    }
}

Environment-Based Adaptation (iPad vs iPhone)

基于环境的自适应(iPad vs iPhone)

swift
struct AdaptiveView: View {
    @Environment(\.horizontalSizeClass) var sizeClass

    var body: some View {
        if sizeClass == .compact {
            VStack { content }   // iPhone
        } else {
            HStack { content }   // iPad / Mac
        }
    }
}
swift
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

带平台样式的共享组件

swift
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
    }
}
swift
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

iOS扩展

Custom Keyboard Extension

自定义键盘扩展

Replaces global hotkey on iOS — users type prompts via custom keyboard.
swift
// 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]
    }
}
在iOS上替代全局快捷键——用户可通过自定义键盘输入提示内容。
swift
// 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)

交互式小组件(主屏幕 + 锁屏)

swift
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()
    }
}
swift
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)

分享扩展(从其他应用保存文本)

swift
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) }
}
swift
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+)

控制中心小组件(iOS 18+)

swift
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")
    }
}
swift
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

URL Scheme深度链接

swift
@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
                    }
                }
        }
    }
}
swift
@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

App Groups for Extensions/Widgets

面向扩展/小组件的App Groups

Extensions (widgets, keyboard, share) run in separate processes. Share data via App Groups.
swift
// 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")
扩展(小组件、键盘、分享)运行在独立进程中,通过App Groups共享数据。
swift
// 1. 为主应用和所有扩展添加App Groups能力
// 2. 使用相同的组标识符:"group.com.yourapp.shared"

// 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])
    }()
}

// 通过UserDefaults实现轻量级共享
let sharedDefaults = UserDefaults(suiteName: "group.com.yourapp.shared")
sharedDefaults?.set(encodedData, forKey: "prompts")

CloudKit Sync with SwiftData

基于SwiftData的CloudKit同步

swift
let config = ModelConfiguration(
    schema: schema,
    isStoredInMemoryOnly: false,
    cloudKitDatabase: .automatic  // Enable iCloud sync
)
swift
let config = ModelConfiguration(
    schema: schema,
    isStoredInMemoryOnly: false,
    cloudKitDatabase: .automatic  // Enable iCloud sync
)

Sync Status Monitoring

同步状态监控

swift
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
        }
    }
}
swift
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

JSON导出/导入

swift
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
    }
}
swift
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

架构版本控制

swift
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)]
    }
}
swift
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

小组件刷新

swift
// After any prompt change — notify widgets
WidgetCenter.shared.reloadAllTimelines()
// Or specific widget
WidgetCenter.shared.reloadTimelines(ofKind: "PromptWidget")
swift
// After any prompt change — notify widgets
WidgetCenter.shared.reloadAllTimelines()
// Or specific widget
WidgetCenter.shared.reloadTimelines(ofKind: "PromptWidget")

macOS → iOS Migration Checklist

macOS → iOS迁移清单

Core Functionality

核心功能

  • All models work on both platforms (no AppKit/UIKit imports)
  • ViewModels have no platform-specific imports
  • Services use protocol abstraction
  • 所有模型可在两个平台正常运行(无AppKit/UIKit导入)
  • ViewModel无平台专属导入
  • 服务采用协议抽象

UI Adaptation

UI适配

  • Navigation adapted (SplitView → Stack on iPhone)
  • Touch targets ≥ 44pt minimum
  • No hover-only interactions (add tap alternatives)
  • Keyboard shortcuts have touch equivalents
  • 导航已适配(SplitView → iPhone用Stack)
  • 触控目标最小≥44pt
  • 无仅悬停的交互(添加点击替代方案)
  • 键盘快捷键有触控等效操作

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
  • 快捷键 → iOS上替换为键盘扩展或小组件
  • 菜单栏 → iOS上替换为应用图标或小组件
  • 浮动面板 → iOS上替换为弹窗或全屏模态视图
  • 右键点击 → 长按上下文菜单

Data

数据

  • CloudKit sync enabled
  • App Groups configured for extensions
  • Shared UserDefaults for lightweight data
  • SwiftData shared container for extensions
  • 已启用CloudKit同步
  • 已为扩展配置App Groups
  • 已设置用于轻量级数据的共享UserDefaults
  • 已为扩展配置SwiftData共享容器

Testing

测试

  • Shared tests pass on both platforms
  • UI tests for each platform
  • Widget previews work
  • Test on real device (not just simulator)
  • 共享测试在两个平台均通过
  • 每个平台都有对应的UI测试
  • 小组件预览正常工作
  • 在真实设备上测试(而非仅模拟器)

Common Pitfalls

常见陷阱

  1. Documents directory differs — abstract file paths, don't hardcode
  2. Keyboard presence on iOS — handle
    keyboardLayoutGuide
    or
    .ignoresSafeArea(.keyboard)
  3. Right-click menus — provide
    .contextMenu
    (works as right-click on Mac, long-press on iOS)
  4. Window management — macOS has multiple windows; iOS is single-window (use
    openWindow
    conditionally)
  5. Status bar — macOS
    MenuBarExtra
    ; no equivalent on iOS (use widget instead)
  1. Documents目录不同——抽象文件路径,不要硬编码
  2. iOS上的键盘存在性——处理
    keyboardLayoutGuide
    或使用
    .ignoresSafeArea(.keyboard)
  3. 右键菜单——提供
    .contextMenu
    (在Mac上为右键点击,在iOS上为长按)
  4. 窗口管理——macOS支持多窗口;iOS为单窗口(按需使用
    openWindow
  5. 状态栏——macOS用
    MenuBarExtra
    ;iOS无等效功能(改用小组件)