feat(ios): add share to support (#15340)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
  * Added iOS sharing for URLs, text, web pages, and images.
* Shared content can be reviewed, titled, and saved to an AFFiNE
workspace.
  * Choose destinations including workspaces, tags, and collections.
* Added previews, attachment handling, import status, retry support, and
success/error feedback.
* Pending shares are processed when AFFiNE opens or returns to the
foreground.
* **Bug Fixes**
* Improved workspace profile handling across different workspace types.
* Onboarding completion is now recorded immediately after successful
sign-in verification.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: DarkSky <25152247+darkskygit@users.noreply.github.com>
This commit is contained in:
keepClamDown
2026-08-27 19:09:51 +08:00
committed by GitHub
parent ca056ae7b9
commit 329839e467
33 changed files with 2723 additions and 48 deletions
@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDisplayName</key>
<string>AFFiNE</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>NSExtensionActivationRule</key>
<dict>
<key>NSExtensionActivationDictionaryVersion</key>
<integer>2</integer>
<key>NSExtensionActivationSupportsText</key>
<true/>
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
<integer>1</integer>
<key>NSExtensionActivationSupportsWebPageWithMaxCount</key>
<integer>1</integer>
<key>NSExtensionActivationSupportsImageWithMaxCount</key>
<integer>1</integer>
</dict>
<key>NSExtensionJavaScriptPreprocessingFile</key>
<string>SafariPageCapture</string>
</dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.share-services</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
</dict>
</dict>
</plist>
@@ -0,0 +1,14 @@
var SafariPageCapture = function () {};
SafariPageCapture.prototype = {
run: function (context) {
var selection = window.getSelection();
context.completionFunction({
title: document.title || '',
url: document.location.href,
selectedText: selection ? selection.toString() : '',
});
},
};
var ExtensionPreprocessingJS = new SafariPageCapture();
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.app.affine.pro</string>
</array>
</dict>
</plist>
@@ -0,0 +1,79 @@
import SwiftUI
struct ShareExtensionView: View {
@ObservedObject var viewModel: ShareViewModel
var onCancel: () -> Void
var onSave: () -> Void
var body: some View {
NavigationStack {
Group {
if viewModel.isLoading {
ProgressView("Reading shared content…")
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
content
}
}
.navigationTitle("AFFiNE")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Not now", action: onCancel)
.disabled(viewModel.isSaving)
}
ToolbarItem(placement: .confirmationAction) {
if viewModel.isSaving {
ProgressView()
} else {
Button(viewModel.actionTitle, action: onSave)
.fontWeight(.semibold)
.disabled(!viewModel.canSave)
}
}
}
}
}
private var content: some View {
Form {
Section {
Text("Choose a workspace in AFFiNE. This item will stay saved until then.")
.font(.footnote)
.foregroundStyle(.secondary)
}
Section {
HStack(alignment: .top, spacing: 12) {
Image(systemName: viewModel.previewImage == nil ? "doc.text" : "photo")
.font(.title2)
.frame(width: 32, height: 32)
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 4) {
TextField("Title", text: $viewModel.title)
.font(.headline)
if !viewModel.previewText.isEmpty {
Text(viewModel.previewText)
.lineLimit(3)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
}
if let image = viewModel.previewImage {
Image(uiImage: image)
.resizable()
.scaledToFit()
.frame(maxHeight: 180)
}
}
if let errorMessage = viewModel.errorMessage {
Section {
Text(errorMessage)
.foregroundStyle(.red)
}
}
}
}
}
@@ -0,0 +1,89 @@
//
// ShareViewController.swift
// ShareExtension
//
import SwiftUI
import UIKit
final class ShareViewController: UIViewController {
private let viewModel = ShareViewModel()
private var hostingController: UIHostingController<ShareExtensionView>?
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
let rootView = ShareExtensionView(
viewModel: viewModel,
onCancel: { [weak self] in
self?.cancel()
},
onSave: { [weak self] in
self?.save()
}
)
let hosting = UIHostingController(rootView: rootView)
hostingController = hosting
addChild(hosting)
view.addSubview(hosting.view)
hosting.view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
hosting.view.topAnchor.constraint(equalTo: view.topAnchor),
hosting.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
hosting.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
hosting.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
hosting.didMove(toParent: self)
Task {
await viewModel.load(from: extensionContext)
}
}
private func cancel() {
extensionContext?.completeRequest(returningItems: nil)
}
private func save() {
Task { [weak self] in
guard let self else { return }
let success = await viewModel.save()
guard success else { return }
_ = await openMainAppIfPossible()
extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
}
}
@discardableResult
private func openMainAppIfPossible() async -> Bool {
let url = ShareInboxConstants.openInboxURL
let openedByContext = await withCheckedContinuation { continuation in
extensionContext?.open(url) { success in
continuation.resume(returning: success)
} ?? continuation.resume(returning: false)
}
let opened = openedByContext || openMainAppViaResponderChain(url)
#if DEBUG
NSLog(
"[AFFiNE Share] open url=%@ extensionContext=%@ final=%@",
url.absoluteString,
openedByContext ? "YES" : "NO",
opened ? "YES" : "NO"
)
#endif
return opened
}
private func openMainAppViaResponderChain(_ url: URL) -> Bool {
var responder: UIResponder? = self
while let current = responder {
if let application = current as? UIApplication {
application.open(url, options: [:], completionHandler: nil)
return true
}
responder = current.next
}
return false
}
}
@@ -0,0 +1,93 @@
import Foundation
import UIKit
@MainActor
final class ShareViewModel: ObservableObject {
@Published var title = ""
@Published var previewText = ""
@Published var previewImage: UIImage?
@Published var isLoading = true
@Published var isSaving = false
@Published var hasSaved = false
@Published var errorMessage: String?
var actionTitle: String {
"Open AFFiNE"
}
var canSave: Bool {
!isLoading
&& !isSaving
&& !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& draft?.content != nil
}
private var draft: SharePayloadDraft?
private let store: ShareInboxStore
init(store: ShareInboxStore = .shared) {
self.store = store
}
func load(from extensionContext: NSExtensionContext?) async {
isLoading = true
defer { isLoading = false }
let items = extensionContext?.inputItems.compactMap { $0 as? NSExtensionItem } ?? []
let built = await SharePayloadBuilder.build(from: items)
draft = built
title = built.title
previewText = built.previewText
errorMessage = built.errorMessage
if let file = built.file {
previewImage = UIImage(data: file.data)?
.preparingThumbnail(of: CGSize(width: 480, height: 480))
}
}
func save() async -> Bool {
guard !isSaving, !hasSaved else { return false }
isSaving = true
defer { isSaving = false }
let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedTitle.isEmpty else {
errorMessage = "Title is required."
return false
}
guard let draft, let content = draft.content else {
errorMessage = draft?.errorMessage ?? "Nothing to share."
return false
}
let itemId = UUID().uuidString
var attachments: [ShareInboxAttachment] = []
var attachmentData: [(ShareInboxAttachment, Data)] = []
if let file = draft.file {
let attachment = ShareInboxAttachment(
fileName: file.fileName,
mimeType: file.mimeType,
relativePath: "\(itemId)/\(file.fileName)"
)
attachments = [attachment]
attachmentData = [(attachment, file.data)]
}
let item = ShareInboxItem(
id: itemId,
title: trimmedTitle,
content: content,
previewText: draft.previewText,
attachments: attachments
)
do {
try store.enqueue(item, attachmentData: attachmentData)
hasSaved = true
return true
} catch {
errorMessage = "Failed to save shared content."
return false
}
}
}