mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-09-01 22:29:44 +08:00
329839e467
<!-- 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>
290 lines
9.3 KiB
Swift
290 lines
9.3 KiB
Swift
import Foundation
|
|
import UIKit
|
|
import UniformTypeIdentifiers
|
|
|
|
enum SharePayloadBuilder {
|
|
private static let maxImageBytes = 12 * 1024 * 1024
|
|
private static let maxTextCharacters = 250_000
|
|
|
|
static func build(from extensionItems: [NSExtensionItem]) async -> SharePayloadDraft {
|
|
var title = "Shared"
|
|
var url: String?
|
|
var text: String?
|
|
var fallbackText: String?
|
|
var file: SharePayloadFile?
|
|
var imageProviderCount = 0
|
|
|
|
for item in extensionItems {
|
|
for provider in item.attachments ?? [] {
|
|
if provider.hasItemConformingToTypeIdentifier(UTType.propertyList.identifier),
|
|
let page = try? await loadSafariPage(from: provider)
|
|
{
|
|
title = page.title ?? title
|
|
url = page.url ?? url
|
|
text = page.selectedText.map {
|
|
String($0.prefix(maxTextCharacters))
|
|
} ?? text
|
|
}
|
|
|
|
if url == nil,
|
|
provider.hasItemConformingToTypeIdentifier(UTType.url.identifier),
|
|
let loadedURL = try? await loadURL(from: provider),
|
|
let normalized = ShareInboxSafety.normalizedWebURL(loadedURL.absoluteString)
|
|
{
|
|
url = normalized
|
|
if title == "Shared" {
|
|
title = loadedURL.host ?? normalized
|
|
}
|
|
}
|
|
|
|
if fallbackText == nil,
|
|
provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier),
|
|
let loadedText = try? await loadText(from: provider)
|
|
{
|
|
let trimmed = loadedText.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if !trimmed.isEmpty, trimmed != url {
|
|
fallbackText = String(trimmed.prefix(maxTextCharacters))
|
|
}
|
|
}
|
|
|
|
if provider.hasItemConformingToTypeIdentifier(UTType.image.identifier) {
|
|
imageProviderCount += 1
|
|
if imageProviderCount > 1 {
|
|
return failure(title: title, message: "Share one image at a time.")
|
|
}
|
|
do {
|
|
file = try await loadImage(from: provider)
|
|
} catch ShareInboxError.payloadTooLarge {
|
|
return failure(title: title, message: "The image must be smaller than 12 MB.")
|
|
} catch {
|
|
return failure(title: title, message: "This image format is not supported.")
|
|
}
|
|
}
|
|
}
|
|
|
|
if let attributedText = nonEmpty(item.attributedContentText?.string),
|
|
attributedText != url
|
|
{
|
|
if title == "Shared" {
|
|
title = firstNonEmptyLine(attributedText)
|
|
}
|
|
if fallbackText == nil {
|
|
fallbackText = String(attributedText.prefix(maxTextCharacters))
|
|
}
|
|
}
|
|
}
|
|
|
|
if text == nil, url == nil {
|
|
text = fallbackText
|
|
}
|
|
if title == "Shared" {
|
|
if let file {
|
|
title = (file.fileName as NSString).deletingPathExtension
|
|
} else if let url, let host = URL(string: url)?.host {
|
|
title = host
|
|
} else if let fallbackText {
|
|
title = firstNonEmptyLine(fallbackText)
|
|
}
|
|
}
|
|
|
|
let content: ShareInboxContent?
|
|
if file != nil {
|
|
content = ShareInboxContent(kind: .image, url: url, text: text)
|
|
} else if let url {
|
|
content = ShareInboxContent(kind: .url, url: url, text: text)
|
|
} else if let text {
|
|
content = ShareInboxContent(kind: .text, url: nil, text: text)
|
|
} else {
|
|
content = nil
|
|
}
|
|
|
|
guard let content else {
|
|
return failure(
|
|
title: title,
|
|
message: "AFFiNE can currently save links, text, or one image."
|
|
)
|
|
}
|
|
|
|
let preview = text ?? url ?? file?.fileName ?? "Shared content"
|
|
return SharePayloadDraft(
|
|
title: sanitizeTitle(title),
|
|
content: content,
|
|
previewText: String(preview.prefix(280)),
|
|
file: file,
|
|
errorMessage: nil
|
|
)
|
|
}
|
|
|
|
private static func failure(title: String, message: String) -> SharePayloadDraft {
|
|
SharePayloadDraft(
|
|
title: sanitizeTitle(title),
|
|
content: nil,
|
|
previewText: "",
|
|
file: nil,
|
|
errorMessage: message
|
|
)
|
|
}
|
|
|
|
private struct SafariPage {
|
|
var title: String?
|
|
var url: String?
|
|
var selectedText: String?
|
|
}
|
|
|
|
private static func loadSafariPage(from provider: NSItemProvider) async throws -> SafariPage {
|
|
let item: Any = try await withCheckedThrowingContinuation { continuation in
|
|
provider.loadItem(
|
|
forTypeIdentifier: UTType.propertyList.identifier,
|
|
options: nil
|
|
) { item, error in
|
|
if let error {
|
|
continuation.resume(throwing: error)
|
|
} else if let item {
|
|
continuation.resume(returning: item)
|
|
} else {
|
|
continuation.resume(throwing: ShareInboxError.invalidPayload)
|
|
}
|
|
}
|
|
}
|
|
|
|
let dictionary: [String: Any]?
|
|
if let value = item as? [String: Any] {
|
|
dictionary = value
|
|
} else if let data = item as? Data {
|
|
dictionary = try? PropertyListSerialization.propertyList(
|
|
from: data,
|
|
options: [],
|
|
format: nil
|
|
) as? [String: Any]
|
|
} else {
|
|
dictionary = nil
|
|
}
|
|
guard let dictionary else { throw ShareInboxError.invalidPayload }
|
|
let result =
|
|
dictionary[NSExtensionJavaScriptPreprocessingResultsKey] as? [String: Any]
|
|
?? dictionary
|
|
let pageURL = (result["url"] as? String).flatMap(ShareInboxSafety.normalizedWebURL)
|
|
return SafariPage(
|
|
title: nonEmpty(result["title"] as? String),
|
|
url: pageURL,
|
|
selectedText: nonEmpty(result["selectedText"] as? String)
|
|
)
|
|
}
|
|
|
|
private static func loadURL(from provider: NSItemProvider) async throws -> URL {
|
|
try await withCheckedThrowingContinuation { continuation in
|
|
provider.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { item, error in
|
|
if let error {
|
|
continuation.resume(throwing: error)
|
|
} else if let url = item as? URL {
|
|
continuation.resume(returning: url)
|
|
} else if let value = item as? String, let url = URL(string: value) {
|
|
continuation.resume(returning: url)
|
|
} else {
|
|
continuation.resume(throwing: ShareInboxError.invalidPayload)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private static func loadText(from provider: NSItemProvider) async throws -> String {
|
|
try await withCheckedThrowingContinuation { continuation in
|
|
provider.loadItem(
|
|
forTypeIdentifier: UTType.plainText.identifier,
|
|
options: nil
|
|
) { item, error in
|
|
if let error {
|
|
continuation.resume(throwing: error)
|
|
} else if let text = item as? String {
|
|
continuation.resume(returning: text)
|
|
} else if let attributed = item as? NSAttributedString {
|
|
continuation.resume(returning: attributed.string)
|
|
} else {
|
|
continuation.resume(throwing: ShareInboxError.invalidPayload)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private static func loadImage(from provider: NSItemProvider) async throws -> SharePayloadFile {
|
|
let item: Any = try await withCheckedThrowingContinuation { continuation in
|
|
provider.loadItem(forTypeIdentifier: UTType.image.identifier, options: nil) { item, error in
|
|
if let error {
|
|
continuation.resume(throwing: error)
|
|
} else if let item {
|
|
continuation.resume(returning: item)
|
|
} else {
|
|
continuation.resume(throwing: ShareInboxError.invalidPayload)
|
|
}
|
|
}
|
|
}
|
|
|
|
let data: Data
|
|
let suggestedName: String?
|
|
if let value = item as? Data {
|
|
data = value
|
|
suggestedName = provider.suggestedName
|
|
} else if let url = item as? URL, url.isFileURL {
|
|
let values = try url.resourceValues(forKeys: [.fileSizeKey])
|
|
if let fileSize = values.fileSize, fileSize > maxImageBytes {
|
|
throw ShareInboxError.payloadTooLarge
|
|
}
|
|
data = try Data(contentsOf: url, options: .mappedIfSafe)
|
|
suggestedName = url.lastPathComponent
|
|
} else if let image = item as? UIImage, let jpeg = image.jpegData(compressionQuality: 0.9) {
|
|
data = jpeg
|
|
suggestedName = provider.suggestedName
|
|
} else {
|
|
throw ShareInboxError.invalidPayload
|
|
}
|
|
|
|
guard data.count <= maxImageBytes else { throw ShareInboxError.payloadTooLarge }
|
|
guard let mimeType = ShareInboxSafety.detectRasterImageMimeType(data) else {
|
|
throw ShareInboxError.invalidPayload
|
|
}
|
|
let fileExtension = fileExtension(for: mimeType)
|
|
let baseName = nonEmpty(suggestedName)
|
|
.map { ($0 as NSString).lastPathComponent }
|
|
.flatMap { nonEmpty(($0 as NSString).deletingPathExtension) }
|
|
?? "shared-image"
|
|
return SharePayloadFile(
|
|
data: data,
|
|
mimeType: mimeType,
|
|
fileName: "\(baseName).\(fileExtension)"
|
|
)
|
|
}
|
|
|
|
private static func nonEmpty(_ value: String?) -> String? {
|
|
guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!trimmed.isEmpty
|
|
else {
|
|
return nil
|
|
}
|
|
return trimmed
|
|
}
|
|
|
|
private static func sanitizeTitle(_ value: String) -> String {
|
|
String((nonEmpty(value) ?? "Shared").prefix(120))
|
|
}
|
|
|
|
private static func firstNonEmptyLine(_ value: String) -> String {
|
|
value
|
|
.split(whereSeparator: \.isNewline)
|
|
.lazy
|
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
.first { !$0.isEmpty }
|
|
.map { String($0.prefix(120)) }
|
|
?? "Shared text"
|
|
}
|
|
|
|
private static func fileExtension(for mimeType: String) -> String {
|
|
switch mimeType {
|
|
case "image/png": "png"
|
|
case "image/gif": "gif"
|
|
case "image/webp": "webp"
|
|
case "image/heic": "heic"
|
|
default: "jpg"
|
|
}
|
|
}
|
|
}
|