mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-09-03 07:10:46 +08:00
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:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user