
App Extensions
- 2 installs
- 591 repo stars
- Updated July 24, 2026
- rshankras/claude-code-apple-skills
Generates iOS/macOS app extension targets (Share, Action, Keyboard, Safari Web) with App Group data sharing between the host app and its extensions.
About
Generates production app extension infrastructure for Share, Action, Keyboard, and Safari Web extensions plus App Group data sharing. A developer uses it to add an extension target that exchanges data with the host app.
- Supports Share, Action, Keyboard, and Safari Web extension types
- Sets up App Group data sharing and detects existing extension targets
App Extensions by the numbers
- 2 all-time installs (skills.sh)
- Ranked #888 of 1,039 Mobile Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/rshankras/claude-code-apple-skills --skill app-extensionsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2 |
|---|---|
| repo stars | ★ 591 |
| Last updated | July 24, 2026 |
| Repository | rshankras/claude-code-apple-skills ↗ |
What it does
Generates iOS/macOS app extension targets (Share, Action, Keyboard, Safari Web) with App Group data sharing between the host app and its extensions.
Files
App Extensions Generator
Generate production app extension infrastructure -- Share Extensions for receiving content from other apps, Action Extensions for manipulating content in-place, Keyboard Extensions for custom input, and Safari Web Extensions for browser integration. Includes App Group data sharing between the host app and extensions.
When This Skill Activates
Use this skill when the user:
- Asks to "add a share extension" or "share sheet extension"
- Wants to "receive content from other apps" or "accept shared content"
- Mentions "action extension" or "content manipulation extension"
- Wants to "add a custom keyboard" or "keyboard extension"
- Asks about "Safari extension" or "Safari web extension"
- Mentions "app extension" or "extension target" generically
- Wants to "share data between app and extension" or "App Groups"
Pre-Generation Checks
1. Project Context Detection
- [ ] Check deployment target (iOS 16+ / macOS 13+)
- [ ] Check Swift version (requires Swift 5.9+)
- [ ] Identify project structure (find .xcodeproj or Package.swift)
- [ ] Identify source file locations
2. Existing Extension Detection
Search for existing extension targets:
Glob: **/*Extension*/*.swift, **/*Extension*/Info.plist
Grep: "NSExtensionPointIdentifier" or "NSExtensionPrincipalClass"If existing extensions found:
- Ask if user wants to add another or modify existing
- Identify existing App Groups configuration
3. App Groups Detection
Check for existing App Groups setup:
Glob: **/*.entitlements
Grep: "com.apple.security.application-groups"If App Groups exist, reuse the existing group identifier.
Configuration Questions
Ask user via AskUserQuestion:
1. What type of extension?
- Share Extension (accept content from other apps, display share UI)
- Action Extension (manipulate content in-place -- transform text, edit images)
- Keyboard Extension (custom input keyboard with KeyboardViewController)
- Safari Web Extension (inject JavaScript/CSS into web pages)
2. What content types does it handle?
- Text (plain text, rich text)
- URLs (web links, deep links)
- Images (photos, screenshots)
- Files (documents, PDFs, archives)
- All (any content type)
3. Does the extension need to share data with the main app?
- Yes -- needs App Groups (shared UserDefaults, shared file container, shared Keychain)
- No -- extension is self-contained
Generation Process
Step 1: Read Templates
Read templates.md for production Swift code.
Step 2: Create Extension Files
Based on extension type selected:
Share Extension: 1. ShareViewController.swift -- Main share extension view controller with content handling 2. Info.plist -- Extension configuration with activation rules
Action Extension: 3. ActionViewController.swift -- Action extension with content manipulation 4. Info.plist -- Extension configuration
Keyboard Extension: 5. KeyboardViewController.swift -- Custom keyboard with UIInputViewController 6. Info.plist -- Keyboard extension configuration
Safari Web Extension: 7. SafariWebExtensionHandler.swift -- Native message handler 8. manifest.json -- Web extension manifest 9. content.js -- Content script template
Step 3: Create Shared Infrastructure
If data sharing selected: 10. SharedDataManager.swift -- App Group data sharing helper
Step 4: Determine File Location
Extensions are separate targets:
- Share Extension ->
ShareExtension/ - Action Extension ->
ActionExtension/ - Keyboard Extension ->
KeyboardExtension/ - Safari Web Extension ->
SafariWebExtension/ - Shared code ->
Shared/or within main app target
Output Format
After generation, provide:
Files Created
Share Extension:
ShareExtension/
├── ShareViewController.swift # Main share extension view controller
└── Info.plist # Extension activation rules & configAction Extension:
ActionExtension/
├── ActionViewController.swift # Content manipulation controller
└── Info.plist # Extension activation rules & configKeyboard Extension:
KeyboardExtension/
├── KeyboardViewController.swift # UIInputViewController subclass
└── Info.plist # Keyboard extension configSafari Web Extension:
SafariWebExtension/
├── SafariWebExtensionHandler.swift # Native message handler
└── Resources/
├── manifest.json # Web extension manifest
├── content.js # Content script
└── popup.html # Popup UI (optional)Shared (if data sharing enabled):
Shared/
└── SharedDataManager.swift # App Group data sharingIntegration Steps
1. Add Extension Target in Xcode:
- File > New > Target
- Select the extension type (Share, Action, Keyboard, Safari Web)
- Configure bundle identifier:
com.yourapp.ShareExtension - Xcode creates the target with boilerplate -- replace with generated code
2. Configure App Groups (if data sharing):
- Select main app target > Signing & Capabilities > Add "App Groups"
- Add group:
group.com.yourapp.shared - Select extension target > Signing & Capabilities > Add "App Groups"
- Add the same group identifier
3. Share code between targets:
- Add shared files to both the app and extension targets
- Or create a shared framework target
Extension Content Handling
Accept shared URLs:
if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) {
provider.loadItem(forTypeIdentifier: UTType.url.identifier) { item, error in
guard let url = item as? URL else { return }
// Process URL
}
}Accept shared images:
if provider.hasItemConformingToTypeIdentifier(UTType.image.identifier) {
provider.loadItem(forTypeIdentifier: UTType.image.identifier) { item, error in
if let imageURL = item as? URL {
let imageData = try? Data(contentsOf: imageURL)
// Process image data
}
}
}Share data to main app via App Groups:
let shared = SharedDataManager.shared
shared.saveSharedContent(url.absoluteString, forKey: "lastSharedURL")Testing
Share Extension: 1. Build and run the extension scheme 2. Select a host app (Safari, Photos, etc.) 3. Share content and verify your extension appears 4. Test with different content types
Action Extension: 1. Build and run the extension scheme 2. Open content in a supported app 3. Tap the share/action button and select your action
Keyboard Extension: 1. Build and run the keyboard extension scheme 2. Go to Settings > General > Keyboard > Keyboards > Add New Keyboard 3. Select your keyboard 4. Open any text field and switch to your keyboard
Safari Web Extension: 1. Build and run the app (which contains the extension) 2. Go to Safari > Settings > Extensions 3. Enable your extension 4. Navigate to a web page and verify behavior
Extension Lifecycle and Limits
Memory Limits
- Share Extension: ~120 MB
- Action Extension: ~120 MB
- Keyboard Extension: ~48 MB (very constrained)
- Safari Web Extension: ~6 MB for content scripts
Execution Limits
- Extensions must complete work quickly (no long background execution)
- Must call
completeRequest()orcancelRequest()when done - No access to HealthKit, CallKit, or some restricted frameworks
- Network requests are allowed but should be brief
Extension Termination
The system can terminate extensions at any time for resource reclamation. Save state frequently and handle interruption gracefully.
Gotchas
completeRequest Must Be Called
The extension host waits for extensionContext?.completeRequest(returningItems:). If you never call it, the share sheet hangs and the user is stuck. Always call it in both success and error paths.
Keyboard Extensions Need Open Access for Network
By default keyboard extensions have no network access. The user must explicitly grant "Allow Full Access" in Settings. Without it, URLSession calls fail silently. Design your keyboard to work without network and enhance when access is granted.
App Groups Require Matching Identifiers
The App Group identifier must be identical in both the main app and extension entitlements. A typo means UserDefaults(suiteName:) returns a different (empty) container.
Safari Web Extension Content Scripts Run in Isolated World
Content scripts cannot directly access the page's JavaScript variables. Use window.postMessage or the browser messaging API to communicate between content scripts and the page.
Extension Bundle Identifier Convention
Extension bundle identifiers must be prefixed with the main app bundle identifier:
- Main app:
com.yourcompany.myapp - Share Extension:
com.yourcompany.myapp.ShareExtension
References
- templates.md -- All production Swift templates for each extension type
- App Extension Programming Guide
- Creating a Share Extension
- Custom Keyboard Guide
- Safari Web Extensions
- Related:
generators/deep-linking-- Handle URLs received via extensions - Related:
generators/push-notifications-- Notification extensions (Service/Content)
App Extensions Code Templates
Production-ready Swift templates for app extension infrastructure. All code targets iOS 16+ / macOS 13+ and follows Apple's extension architecture guidelines.
ShareViewController.swift
import UIKit
import UniformTypeIdentifiers
import os
/// Share extension view controller that accepts content from other apps.
///
/// Handles URLs, text, images, and files shared via the system share sheet.
/// Processes the shared content and optionally saves it to the main app
/// via App Groups.
///
/// ## Supported Content Types
///
/// Configure supported types in Info.plist under NSExtensionActivationRule.
/// This controller handles:
/// - URLs (web links, deep links)
/// - Plain text and rich text
/// - Images (JPEG, PNG, HEIC)
/// - Files (PDF, documents)
///
/// ## Info.plist Configuration
///
/// ```xml
/// <key>NSExtension</key>
/// <dict>
/// <key>NSExtensionPointIdentifier</key>
/// <string>com.apple.share-services</string>
/// <key>NSExtensionPrincipalClass</key>
/// <string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
/// <key>NSExtensionActivationRule</key>
/// <dict>
/// <key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
/// <integer>1</integer>
/// <key>NSExtensionActivationSupportsText</key>
/// <true/>
/// <key>NSExtensionActivationSupportsImageWithMaxCount</key>
/// <integer>5</integer>
/// </dict>
/// </dict>
/// ```
class ShareViewController: UIViewController {
private let logger = Logger(
subsystem: Bundle.main.bundleIdentifier ?? "com.app.share",
category: "ShareExtension"
)
// MARK: - UI Elements
private lazy var containerView: UIView = {
let view = UIView()
view.backgroundColor = .systemBackground
view.layer.cornerRadius = 16
view.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
view.translatesAutoresizingMaskIntoConstraints = false
return view
}()
private lazy var titleLabel: UILabel = {
let label = UILabel()
label.text = "Save to App"
label.font = .preferredFont(forTextStyle: .headline)
label.textAlignment = .center
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
private lazy var statusLabel: UILabel = {
let label = UILabel()
label.text = "Processing..."
label.font = .preferredFont(forTextStyle: .subheadline)
label.textColor = .secondaryLabel
label.textAlignment = .center
label.numberOfLines = 0
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
private lazy var cancelButton: UIButton = {
let button = UIButton(type: .system)
button.setTitle("Cancel", for: .normal)
button.addTarget(self, action: #selector(cancelAction), for: .touchUpInside)
button.translatesAutoresizingMaskIntoConstraints = false
return button
}()
private lazy var saveButton: UIButton = {
var config = UIButton.Configuration.filled()
config.title = "Save"
config.cornerStyle = .medium
let button = UIButton(configuration: config)
button.addTarget(self, action: #selector(saveAction), for: .touchUpInside)
button.translatesAutoresizingMaskIntoConstraints = false
return button
}()
// MARK: - Shared Content
private var sharedURLs: [URL] = []
private var sharedTexts: [String] = []
private var sharedImages: [Data] = []
// MARK: - Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
handleSharedContent()
}
// MARK: - UI Setup
private func setupUI() {
view.backgroundColor = UIColor.black.withAlphaComponent(0.4)
view.addSubview(containerView)
containerView.addSubview(titleLabel)
containerView.addSubview(statusLabel)
containerView.addSubview(cancelButton)
containerView.addSubview(saveButton)
NSLayoutConstraint.activate([
containerView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
containerView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
containerView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
containerView.heightAnchor.constraint(equalToConstant: 250),
titleLabel.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 20),
titleLabel.centerXAnchor.constraint(equalTo: containerView.centerXAnchor),
statusLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 12),
statusLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 20),
statusLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -20),
cancelButton.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 20),
cancelButton.bottomAnchor.constraint(equalTo: containerView.safeAreaLayoutGuide.bottomAnchor, constant: -20),
saveButton.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -20),
saveButton.bottomAnchor.constraint(equalTo: containerView.safeAreaLayoutGuide.bottomAnchor, constant: -20),
saveButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 100),
])
}
// MARK: - Content Handling
private func handleSharedContent() {
guard let extensionItems = extensionContext?.inputItems as? [NSExtensionItem] else {
complete(with: .noData)
return
}
let group = DispatchGroup()
for item in extensionItems {
for provider in item.attachments ?? [] {
// Handle URLs
if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) {
group.enter()
provider.loadItem(forTypeIdentifier: UTType.url.identifier) { [weak self] item, error in
defer { group.leave() }
if let url = item as? URL {
self?.sharedURLs.append(url)
}
}
}
// Handle plain text
if provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) {
group.enter()
provider.loadItem(forTypeIdentifier: UTType.plainText.identifier) { [weak self] item, error in
defer { group.leave() }
if let text = item as? String {
self?.sharedTexts.append(text)
}
}
}
// Handle images
if provider.hasItemConformingToTypeIdentifier(UTType.image.identifier) {
group.enter()
provider.loadItem(forTypeIdentifier: UTType.image.identifier) { [weak self] item, error in
defer { group.leave() }
if let imageURL = item as? URL,
let imageData = try? Data(contentsOf: imageURL) {
self?.sharedImages.append(imageData)
} else if let image = item as? UIImage,
let imageData = image.jpegData(compressionQuality: 0.8) {
self?.sharedImages.append(imageData)
}
}
}
}
}
group.notify(queue: .main) { [weak self] in
self?.updateStatusLabel()
}
}
private func updateStatusLabel() {
var parts: [String] = []
if !sharedURLs.isEmpty { parts.append("\(sharedURLs.count) URL(s)") }
if !sharedTexts.isEmpty { parts.append("\(sharedTexts.count) text item(s)") }
if !sharedImages.isEmpty { parts.append("\(sharedImages.count) image(s)") }
if parts.isEmpty {
statusLabel.text = "No supported content found"
saveButton.isEnabled = false
} else {
statusLabel.text = "Ready to save: " + parts.joined(separator: ", ")
saveButton.isEnabled = true
}
}
// MARK: - Actions
@objc private func saveAction() {
// Save shared content via App Groups
let shared = SharedDataManager.shared
if let firstURL = sharedURLs.first {
shared.set(firstURL.absoluteString, forKey: "lastSharedURL")
}
if let firstText = sharedTexts.first {
shared.set(firstText, forKey: "lastSharedText")
}
logger.info("Saved shared content: \(self.sharedURLs.count) URLs, \(self.sharedTexts.count) texts, \(self.sharedImages.count) images")
complete(with: .saved)
}
@objc private func cancelAction() {
complete(with: .cancelled)
}
// MARK: - Completion
private enum CompletionReason {
case saved, cancelled, noData
}
private func complete(with reason: CompletionReason) {
switch reason {
case .saved, .noData:
extensionContext?.completeRequest(returningItems: nil)
case .cancelled:
extensionContext?.cancelRequest(withError: NSError(
domain: NSCocoaErrorDomain,
code: NSUserCancelledError
))
}
}
}ActionViewController.swift
import UIKit
import UniformTypeIdentifiers
import os
/// Action extension that manipulates content in-place.
///
/// Action extensions receive content from a host app, allow the user
/// to manipulate it, and return the modified content back to the host.
///
/// ## Info.plist Configuration
///
/// ```xml
/// <key>NSExtension</key>
/// <dict>
/// <key>NSExtensionPointIdentifier</key>
/// <string>com.apple.ui-services</string>
/// <key>NSExtensionPrincipalClass</key>
/// <string>$(PRODUCT_MODULE_NAME).ActionViewController</string>
/// <key>NSExtensionActivationRule</key>
/// <dict>
/// <key>NSExtensionActivationSupportsText</key>
/// <true/>
/// <key>NSExtensionActivationSupportsImageWithMaxCount</key>
/// <integer>1</integer>
/// </dict>
/// </dict>
/// ```
class ActionViewController: UIViewController {
private let logger = Logger(
subsystem: Bundle.main.bundleIdentifier ?? "com.app.action",
category: "ActionExtension"
)
private var inputText: String?
private var inputImage: UIImage?
private lazy var textView: UITextView = {
let tv = UITextView()
tv.font = .preferredFont(forTextStyle: .body)
tv.isEditable = true
tv.translatesAutoresizingMaskIntoConstraints = false
return tv
}()
// MARK: - Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
loadInputContent()
}
// MARK: - UI Setup
private func setupUI() {
title = "Edit Content"
view.backgroundColor = .systemBackground
navigationItem.leftBarButtonItem = UIBarButtonItem(
barButtonSystemItem: .cancel,
target: self,
action: #selector(cancelAction)
)
navigationItem.rightBarButtonItem = UIBarButtonItem(
barButtonSystemItem: .done,
target: self,
action: #selector(doneAction)
)
view.addSubview(textView)
NSLayoutConstraint.activate([
textView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 8),
textView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
textView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
textView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -8),
])
}
// MARK: - Content Loading
private func loadInputContent() {
guard let extensionItems = extensionContext?.inputItems as? [NSExtensionItem] else { return }
for item in extensionItems {
for provider in item.attachments ?? [] {
if provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) {
provider.loadItem(forTypeIdentifier: UTType.plainText.identifier) { [weak self] item, error in
guard let text = item as? String else { return }
DispatchQueue.main.async {
self?.inputText = text
self?.textView.text = text
}
}
return
}
}
}
}
// MARK: - Actions
@objc private func doneAction() {
// Return modified content to the host app
let modifiedText = textView.text ?? ""
let returnItem = NSExtensionItem()
let textData = NSItemProvider(item: modifiedText as NSString, typeIdentifier: UTType.plainText.identifier)
returnItem.attachments = [textData]
extensionContext?.completeRequest(returningItems: [returnItem])
}
@objc private func cancelAction() {
extensionContext?.cancelRequest(withError: NSError(
domain: NSCocoaErrorDomain,
code: NSUserCancelledError
))
}
}KeyboardViewController.swift
import UIKit
/// Custom keyboard extension using UIInputViewController.
///
/// Provides a custom keyboard interface that users can install via
/// Settings > General > Keyboard > Keyboards > Add New Keyboard.
///
/// ## Memory Constraints
///
/// Keyboard extensions are limited to approximately 48 MB of memory.
/// Keep the view hierarchy simple and avoid loading large resources.
///
/// ## Network Access
///
/// By default, keyboard extensions have no network access.
/// The user must enable "Allow Full Access" in Settings for network
/// requests to work. Check `hasFullAccess` before making network calls.
///
/// ## Info.plist Configuration
///
/// ```xml
/// <key>NSExtension</key>
/// <dict>
/// <key>NSExtensionPointIdentifier</key>
/// <string>com.apple.keyboard-service</string>
/// <key>NSExtensionPrincipalClass</key>
/// <string>$(PRODUCT_MODULE_NAME).KeyboardViewController</string>
/// <key>NSExtensionAttributes</key>
/// <dict>
/// <key>IsASCIICapable</key>
/// <false/>
/// <key>PrefersRightToLeft</key>
/// <false/>
/// <key>PrimaryLanguage</key>
/// <string>en-US</string>
/// <key>RequestsOpenAccess</key>
/// <false/>
/// </dict>
/// </dict>
/// ```
class KeyboardViewController: UIInputViewController {
/// Standard keyboard layout rows.
private let keyboardRows: [[String]] = [
["Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P"],
["A", "S", "D", "F", "G", "H", "J", "K", "L"],
["Z", "X", "C", "V", "B", "N", "M"],
]
private var isShifted = false
private lazy var stackView: UIStackView = {
let stack = UIStackView()
stack.axis = .vertical
stack.spacing = 6
stack.distribution = .fillEqually
stack.translatesAutoresizingMaskIntoConstraints = false
return stack
}()
// MARK: - Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
setupKeyboard()
}
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
// Keyboard height constraint
}
// MARK: - Setup
private func setupKeyboard() {
view.addSubview(stackView)
NSLayoutConstraint.activate([
stackView.topAnchor.constraint(equalTo: view.topAnchor, constant: 4),
stackView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 4),
stackView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -4),
stackView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -4),
])
// Add letter rows
for row in keyboardRows {
let rowStack = createRowStack(keys: row)
stackView.addArrangedSubview(rowStack)
}
// Add bottom row with special keys
let bottomRow = createBottomRow()
stackView.addArrangedSubview(bottomRow)
}
private func createRowStack(keys: [String]) -> UIStackView {
let stack = UIStackView()
stack.axis = .horizontal
stack.spacing = 4
stack.distribution = .fillEqually
for key in keys {
let button = createKeyButton(title: key)
button.addTarget(self, action: #selector(keyTapped(_:)), for: .touchUpInside)
stack.addArrangedSubview(button)
}
return stack
}
private func createBottomRow() -> UIStackView {
let stack = UIStackView()
stack.axis = .horizontal
stack.spacing = 4
stack.distribution = .fillEqually
// Globe button (switch keyboard)
let globeButton = createKeyButton(title: "globe", isSystem: true)
globeButton.addTarget(self, action: #selector(handleInputModeList(from:with:)), for: .allTouchEvents)
stack.addArrangedSubview(globeButton)
// Space bar
let spaceButton = createKeyButton(title: "space")
spaceButton.addTarget(self, action: #selector(spaceTapped), for: .touchUpInside)
stack.addArrangedSubview(spaceButton)
stack.addArrangedSubview(spaceButton) // Double width by adding twice -- in production use constraints
// Backspace
let backspaceButton = createKeyButton(title: "delete.left", isSystem: true)
backspaceButton.addTarget(self, action: #selector(backspaceTapped), for: .touchUpInside)
stack.addArrangedSubview(backspaceButton)
// Return
let returnButton = createKeyButton(title: "Return")
returnButton.addTarget(self, action: #selector(returnTapped), for: .touchUpInside)
stack.addArrangedSubview(returnButton)
return stack
}
private func createKeyButton(title: String, isSystem: Bool = false) -> UIButton {
var config = UIButton.Configuration.filled()
config.cornerStyle = .medium
config.baseBackgroundColor = .secondarySystemBackground
config.baseForegroundColor = .label
if isSystem {
config.image = UIImage(systemName: title)
} else {
config.title = title
}
let button = UIButton(configuration: config)
button.titleLabel?.font = .systemFont(ofSize: 22)
return button
}
// MARK: - Key Actions
@objc private func keyTapped(_ sender: UIButton) {
guard let title = sender.titleLabel?.text else { return }
let character = isShifted ? title.uppercased() : title.lowercased()
textDocumentProxy.insertText(character)
// Auto-lowercase after typing
if isShifted {
isShifted = false
}
}
@objc private func spaceTapped() {
textDocumentProxy.insertText(" ")
}
@objc private func backspaceTapped() {
textDocumentProxy.deleteBackward()
}
@objc private func returnTapped() {
textDocumentProxy.insertText("\n")
}
// MARK: - Text Input
override func textWillChange(_ textInput: (any UITextInput)?) {
// Called before text changes in the document
}
override func textDidChange(_ textInput: (any UITextInput)?) {
// Called after text changes -- update key appearance if needed
}
}SafariWebExtensionHandler.swift
import SafariServices
import os
/// Native message handler for Safari Web Extension.
///
/// Receives messages from the web extension's JavaScript code and
/// responds with native functionality. Bridges the gap between
/// browser content scripts and native Swift capabilities.
///
/// ## Message Flow
///
/// 1. Content script calls `browser.runtime.sendNativeMessage()`
/// 2. System delivers the message to this handler
/// 3. Handler processes the message and returns a response
/// 4. Content script receives the response in its callback
///
/// ## JavaScript (content.js)
///
/// ```javascript
/// browser.runtime.sendNativeMessage(
/// "application.id",
/// { action: "getContent", url: window.location.href },
/// function(response) {
/// console.log("Received:", response);
/// }
/// );
/// ```
class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling {
private let logger = Logger(
subsystem: Bundle.main.bundleIdentifier ?? "com.app.safari",
category: "SafariExtension"
)
func beginRequest(with context: NSExtensionContext) {
guard let message = extractMessage(from: context) else {
context.completeRequest(returningItems: nil)
return
}
logger.info("Received message from web extension: \(message)")
let action = message["action"] as? String ?? "unknown"
// Route to appropriate handler
let response: [String: Any]
switch action {
case "getContent":
response = handleGetContent(message: message)
case "saveContent":
response = handleSaveContent(message: message)
case "getSettings":
response = handleGetSettings()
default:
response = ["error": "Unknown action: \(action)"]
}
// Send response back to JavaScript
let responseItem = NSExtensionItem()
responseItem.userInfo = [SFExtensionMessageKey: response]
context.completeRequest(returningItems: [responseItem])
}
// MARK: - Message Extraction
private func extractMessage(from context: NSExtensionContext) -> [String: Any]? {
guard let item = context.inputItems.first as? NSExtensionItem else { return nil }
return item.userInfo?[SFExtensionMessageKey] as? [String: Any]
}
// MARK: - Action Handlers
private func handleGetContent(message: [String: Any]) -> [String: Any] {
let url = message["url"] as? String ?? ""
// TODO: Process the URL or return stored data
return [
"status": "success",
"url": url,
"data": "Processed content for \(url)"
]
}
private func handleSaveContent(message: [String: Any]) -> [String: Any] {
guard let content = message["content"] as? String,
let url = message["url"] as? String else {
return ["status": "error", "message": "Missing content or URL"]
}
// Save via App Groups for main app access
let shared = SharedDataManager.shared
shared.set(content, forKey: "safari_saved_content")
shared.set(url, forKey: "safari_saved_url")
logger.info("Saved content from \(url)")
return ["status": "success", "message": "Content saved"]
}
private func handleGetSettings() -> [String: Any] {
let shared = SharedDataManager.shared
return [
"status": "success",
"settings": [
"enabled": shared.bool(forKey: "extensionEnabled"),
"theme": shared.string(forKey: "extensionTheme") ?? "auto"
]
]
}
}SharedDataManager.swift
import Foundation
import os
/// Manages data sharing between the main app and its extensions via App Groups.
///
/// Uses a shared `UserDefaults` suite and a shared file container
/// to pass data between the app and its extensions (Share, Action,
/// Keyboard, Safari, Widgets).
///
/// ## Setup Requirements
///
/// 1. Enable "App Groups" capability on both the main app and extension targets
/// 2. Add the same group identifier to both (e.g., `group.com.yourapp.shared`)
/// 3. Match the `suiteName` constant below to your group identifier
///
/// ## Usage
///
/// ```swift
/// let shared = SharedDataManager.shared
///
/// // Write from extension
/// shared.set("https://example.com", forKey: "lastSharedURL")
///
/// // Read from main app
/// let url = shared.string(forKey: "lastSharedURL")
///
/// // Share files
/// let fileURL = shared.sharedContainerURL?.appendingPathComponent("data.json")
/// ```
final class SharedDataManager: Sendable {
static let shared = SharedDataManager()
/// The App Group identifier. Must match the identifier configured
/// in both the app and extension entitlements.
private let suiteName = "group.com.yourapp.shared"
private let logger = Logger(
subsystem: Bundle.main.bundleIdentifier ?? "com.app",
category: "SharedData"
)
// MARK: - UserDefaults Access
/// Shared UserDefaults accessible by both the app and extensions.
///
/// Returns nil if the App Group is not properly configured.
var defaults: UserDefaults? {
UserDefaults(suiteName: suiteName)
}
/// Set a value in shared UserDefaults.
func set(_ value: Any?, forKey key: String) {
guard let defaults else {
logger.error("Failed to access shared UserDefaults. Check App Group configuration.")
return
}
defaults.set(value, forKey: key)
}
/// Get a string from shared UserDefaults.
func string(forKey key: String) -> String? {
defaults?.string(forKey: key)
}
/// Get a boolean from shared UserDefaults.
func bool(forKey key: String) -> Bool {
defaults?.bool(forKey: key) ?? false
}
/// Get data from shared UserDefaults.
func data(forKey key: String) -> Data? {
defaults?.data(forKey: key)
}
/// Remove a value from shared UserDefaults.
func remove(forKey key: String) {
defaults?.removeObject(forKey: key)
}
// MARK: - File Container Access
/// URL to the shared file container.
///
/// Use this for sharing files (images, documents, databases) between
/// the app and extensions. Returns nil if App Group is not configured.
var sharedContainerURL: URL? {
FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: suiteName
)
}
/// Write data to a file in the shared container.
///
/// - Parameters:
/// - data: The data to write.
/// - filename: The filename (e.g., "shared-data.json").
/// - Returns: The URL of the written file, or nil on failure.
@discardableResult
func writeFile(_ data: Data, named filename: String) -> URL? {
guard let containerURL = sharedContainerURL else {
logger.error("Shared container not available. Check App Group configuration.")
return nil
}
let fileURL = containerURL.appendingPathComponent(filename)
do {
try data.write(to: fileURL, options: .atomic)
return fileURL
} catch {
logger.error("Failed to write shared file: \(error.localizedDescription)")
return nil
}
}
/// Read data from a file in the shared container.
///
/// - Parameter filename: The filename to read.
/// - Returns: The file data, or nil if the file doesn't exist.
func readFile(named filename: String) -> Data? {
guard let containerURL = sharedContainerURL else {
logger.error("Shared container not available. Check App Group configuration.")
return nil
}
let fileURL = containerURL.appendingPathComponent(filename)
return try? Data(contentsOf: fileURL)
}
/// Delete a file from the shared container.
func deleteFile(named filename: String) {
guard let containerURL = sharedContainerURL else { return }
let fileURL = containerURL.appendingPathComponent(filename)
try? FileManager.default.removeItem(at: fileURL)
}
// MARK: - Codable Convenience
/// Save a Codable object to shared UserDefaults.
func saveCodable<T: Encodable>(_ value: T, forKey key: String) {
guard let data = try? JSONEncoder().encode(value) else {
logger.error("Failed to encode \(T.self) for shared storage")
return
}
set(data, forKey: key)
}
/// Load a Codable object from shared UserDefaults.
func loadCodable<T: Decodable>(_ type: T.Type, forKey key: String) -> T? {
guard let data = data(forKey: key) else { return nil }
return try? JSONDecoder().decode(type, from: data)
}
// MARK: - Keychain Sharing
/// Save a string to the shared Keychain.
///
/// Requires Keychain Sharing capability with matching access group.
func saveToKeychain(value: String, forKey key: String, accessGroup: String? = nil) -> Bool {
guard let data = value.data(using: .utf8) else { return false }
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecValueData as String: data,
]
if let accessGroup {
query[kSecAttrAccessGroup as String] = accessGroup
}
// Delete existing item first
SecItemDelete(query as CFDictionary)
let status = SecItemAdd(query as CFDictionary, nil)
return status == errSecSuccess
}
/// Load a string from the shared Keychain.
func loadFromKeychain(forKey key: String, accessGroup: String? = nil) -> String? {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
if let accessGroup {
query[kSecAttrAccessGroup as String] = accessGroup
}
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess,
let data = result as? Data else { return nil }
return String(data: data, encoding: .utf8)
}
}