Some checks failed
Build & Test / Analyze (javascript, affine) (push) Failing after 4s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 4s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 3s
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 4s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 3s
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Release / Canary Gate (push) Has been skipped
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Copilot Test Automatically / Setup Test (push) Failing after 1s
Release / Prepare (push) Failing after 2s
Release / Release Cloud (push) Has been skipped
Release / Release Desktop (push) Has been skipped
Release / Release Docker Image (push) Has been skipped
Release / Release Mobile (push) Has been skipped
519 lines
16 KiB
Swift
519 lines
16 KiB
Swift
import Foundation
|
|
import PDFKit
|
|
import UIKit
|
|
import UniformTypeIdentifiers
|
|
|
|
enum SharePayloadBuilder {
|
|
static let maxImageBytes = 12 * 1024 * 1024
|
|
private static let maxTextCharacters = 250_000
|
|
typealias FileCopy = (URL, URL) throws -> Void
|
|
typealias CoordinatedRead = (URL, (URL) throws -> Void) throws -> Void
|
|
|
|
private struct ImageFileRepresentationUnavailable: Error {}
|
|
|
|
private final class ProviderLoadState<Value>: @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private let discard: (Value) -> Void
|
|
private var continuation: CheckedContinuation<Value, Error>?
|
|
private var progress: Progress?
|
|
private var isFinished = false
|
|
private var isCancelled = false
|
|
|
|
init(discard: @escaping (Value) -> Void) {
|
|
self.discard = discard
|
|
}
|
|
|
|
func install(_ continuation: CheckedContinuation<Value, Error>) {
|
|
lock.lock()
|
|
if isCancelled {
|
|
lock.unlock()
|
|
continuation.resume(throwing: CancellationError())
|
|
return
|
|
}
|
|
self.continuation = continuation
|
|
lock.unlock()
|
|
}
|
|
|
|
func install(_ progress: Progress?) {
|
|
lock.lock()
|
|
if isCancelled {
|
|
lock.unlock()
|
|
progress?.cancel()
|
|
return
|
|
}
|
|
if !isFinished {
|
|
self.progress = progress
|
|
}
|
|
lock.unlock()
|
|
}
|
|
|
|
func resume(with result: Result<Value, Error>) {
|
|
lock.lock()
|
|
guard !isFinished else {
|
|
lock.unlock()
|
|
if case .success(let value) = result {
|
|
discard(value)
|
|
}
|
|
return
|
|
}
|
|
isFinished = true
|
|
let continuation = continuation
|
|
self.continuation = nil
|
|
progress = nil
|
|
lock.unlock()
|
|
continuation?.resume(with: result)
|
|
}
|
|
|
|
func cancel() {
|
|
lock.lock()
|
|
guard !isFinished else {
|
|
lock.unlock()
|
|
return
|
|
}
|
|
isFinished = true
|
|
isCancelled = true
|
|
let continuation = continuation
|
|
let progress = progress
|
|
self.continuation = nil
|
|
self.progress = nil
|
|
lock.unlock()
|
|
progress?.cancel()
|
|
continuation?.resume(throwing: CancellationError())
|
|
}
|
|
}
|
|
|
|
static func build(from extensionItems: [NSExtensionItem]) async -> SharePayloadDraft {
|
|
removeStaleStagingDirectories()
|
|
var title = "Shared"
|
|
var url: String?
|
|
var text: String?
|
|
var fallbackText: String?
|
|
var file: SharePayloadFile?
|
|
var contexts: [(item: NSExtensionItem, providers: [ProviderContext])] = []
|
|
|
|
defer {
|
|
if Task.isCancelled, let file {
|
|
removeOwnedStagingFile(at: file.ownedStagingURL)
|
|
}
|
|
}
|
|
|
|
for item in extensionItems {
|
|
var providers: [ProviderContext] = []
|
|
for provider in item.attachments ?? [] {
|
|
providers.append(await providerContext(for: provider))
|
|
guard !Task.isCancelled else {
|
|
return failure(title: title, message: "Share cancelled.")
|
|
}
|
|
}
|
|
contexts.append((item: item, providers: providers))
|
|
}
|
|
|
|
let containsWebURL = contexts.flatMap(\.providers).contains { $0.webURL != nil }
|
|
let localBinaryProviders = contexts.flatMap(\.providers).filter {
|
|
($0.hasImage || $0.hasPDF) && !$0.blocksBinaryFallback
|
|
}
|
|
guard localBinaryProviders.count <= 1 else {
|
|
return failure(title: title, message: "Share one image or PDF at a time.")
|
|
}
|
|
|
|
for context in contexts {
|
|
for providerContext in context.providers {
|
|
guard !Task.isCancelled else {
|
|
return failure(title: title, message: "Share cancelled.")
|
|
}
|
|
let provider = providerContext.provider
|
|
if let page = providerContext.safariPage {
|
|
title = page.title ?? title
|
|
url = page.url ?? url
|
|
text = page.selectedText.map {
|
|
String($0.prefix(maxTextCharacters))
|
|
} ?? text
|
|
}
|
|
|
|
if let webURL = providerContext.webURL {
|
|
url = url ?? webURL.absoluteString
|
|
if title == "Shared" {
|
|
title = webURL.host ?? webURL.absoluteString
|
|
}
|
|
}
|
|
|
|
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))
|
|
}
|
|
}
|
|
guard !Task.isCancelled else {
|
|
return failure(title: title, message: "Share cancelled.")
|
|
}
|
|
|
|
let hasImage = providerContext.hasImage
|
|
&& !providerContext.blocksBinaryFallback
|
|
&& !containsWebURL
|
|
let hasPDF = providerContext.hasPDF
|
|
&& !providerContext.blocksBinaryFallback
|
|
&& !containsWebURL
|
|
if hasImage || hasPDF {
|
|
do {
|
|
file = try await (hasPDF ? loadPDF(from: provider) : loadImage(from: provider))
|
|
try Task.checkCancellation()
|
|
} catch is CancellationError {
|
|
return failure(title: title, message: "Share cancelled.")
|
|
} catch ShareInboxError.payloadTooLarge {
|
|
let message = hasPDF
|
|
? "The PDF must be smaller than 64 MB."
|
|
: "The image must be smaller than 12 MB."
|
|
return failure(title: title, message: message)
|
|
} catch {
|
|
let message = hasPDF
|
|
? "This PDF file is not supported."
|
|
: "This image format is not supported."
|
|
return failure(title: title, message: message)
|
|
}
|
|
}
|
|
}
|
|
|
|
if let attributedText = nonEmpty(context.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.name 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 let file {
|
|
content = ShareInboxContent(
|
|
kind: file.mimeType == "application/pdf" ? .pdf : .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, one image, or one PDF."
|
|
)
|
|
}
|
|
|
|
let preview = text ?? url ?? file?.name ?? "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 struct ProviderContext {
|
|
let provider: NSItemProvider
|
|
let safariPage: SafariPage?
|
|
let webURL: URL?
|
|
let blocksBinaryFallback: Bool
|
|
let hasImage: Bool
|
|
let hasPDF: Bool
|
|
|
|
}
|
|
|
|
private static func providerContext(for provider: NSItemProvider) async -> ProviderContext {
|
|
let hasPropertyList = provider.hasItemConformingToTypeIdentifier(UTType.propertyList.identifier)
|
|
let hasURL = provider.hasItemConformingToTypeIdentifier(UTType.url.identifier)
|
|
let safariPage = hasPropertyList
|
|
? try? await loadSafariPage(from: provider)
|
|
: nil
|
|
var loadedURL = safariPage?.url.flatMap(URL.init(string:))
|
|
if loadedURL == nil, hasURL, !Task.isCancelled {
|
|
loadedURL = try? await loadURL(from: provider)
|
|
}
|
|
let webURL = loadedURL.flatMap { ShareInboxSafety.normalizedWebURL($0.absoluteString) }
|
|
.flatMap(URL.init(string:))
|
|
return ProviderContext(
|
|
provider: provider,
|
|
safariPage: safariPage,
|
|
webURL: webURL,
|
|
blocksBinaryFallback: (hasPropertyList || hasURL) && loadedURL?.isFileURL != true,
|
|
hasImage: provider.hasItemConformingToTypeIdentifier(UTType.image.identifier),
|
|
hasPDF: provider.hasItemConformingToTypeIdentifier(UTType.pdf.identifier)
|
|
)
|
|
}
|
|
|
|
private static func loadSafariPage(from provider: NSItemProvider) async throws -> SafariPage {
|
|
let item: Any = try await loadProviderValue { completion in
|
|
provider.loadItem(
|
|
forTypeIdentifier: UTType.propertyList.identifier,
|
|
options: nil
|
|
) { item, error in
|
|
if let error {
|
|
completion(.failure(error))
|
|
} else if let item {
|
|
completion(.success(item))
|
|
} else {
|
|
completion(.failure(ShareInboxError.invalidPayload))
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
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 {
|
|
let item: Any? = try? await loadProviderValue { completion in
|
|
provider.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { item, error in
|
|
if let error {
|
|
completion(.failure(error))
|
|
} else {
|
|
completion(.success(item))
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
try Task.checkCancellation()
|
|
if let url = url(from: item) { return url }
|
|
guard provider.canLoadObject(ofClass: NSURL.self) else {
|
|
throw ShareInboxError.invalidPayload
|
|
}
|
|
let object: any NSItemProviderReading = try await loadProviderValue { completion in
|
|
provider.loadObject(ofClass: NSURL.self) { url, error in
|
|
if let error {
|
|
completion(.failure(error))
|
|
} else if let url {
|
|
completion(.success(url))
|
|
} else {
|
|
completion(.failure(ShareInboxError.invalidPayload))
|
|
}
|
|
}
|
|
}
|
|
guard let url = object as? NSURL else { throw ShareInboxError.invalidPayload }
|
|
return url as URL
|
|
}
|
|
|
|
private static func url(from item: Any?) -> URL? {
|
|
if let url = item as? URL { return url }
|
|
if let url = item as? NSURL { return url as URL }
|
|
if let value = item as? String { return URL(string: value) }
|
|
return nil
|
|
}
|
|
|
|
private static func loadText(from provider: NSItemProvider) async throws -> String {
|
|
try await loadProviderValue { completion in
|
|
provider.loadItem(
|
|
forTypeIdentifier: UTType.plainText.identifier,
|
|
options: nil
|
|
) { item, error in
|
|
if let error {
|
|
completion(.failure(error))
|
|
} else if let text = item as? String {
|
|
completion(.success(text))
|
|
} else if let attributed = item as? NSAttributedString {
|
|
completion(.success(attributed.string))
|
|
} else {
|
|
completion(.failure(ShareInboxError.invalidPayload))
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private static func loadImage(from provider: NSItemProvider) async throws -> SharePayloadFile {
|
|
let suggestedName = provider.suggestedName
|
|
let typeIdentifier = provider.registeredTypeIdentifiers.first { identifier in
|
|
guard identifier != UTType.image.identifier, let type = UTType(identifier) else {
|
|
return false
|
|
}
|
|
return type.conforms(to: .image)
|
|
} ?? UTType.image.identifier
|
|
do {
|
|
return try await loadProviderValue(discard: { file in
|
|
removeOwnedStagingFile(at: file.ownedStagingURL)
|
|
}) { completion in
|
|
provider.loadInPlaceFileRepresentation(forTypeIdentifier: typeIdentifier) { url, _, error in
|
|
if let error {
|
|
completion(.failure(error))
|
|
return
|
|
}
|
|
guard let url, url.isFileURL else {
|
|
completion(.failure(ImageFileRepresentationUnavailable()))
|
|
return
|
|
}
|
|
do {
|
|
completion(.success(try stageImage(
|
|
from: url,
|
|
suggestedName: suggestedName ?? url.lastPathComponent
|
|
)))
|
|
} catch {
|
|
completion(.failure(error))
|
|
}
|
|
}
|
|
}
|
|
} catch is CancellationError {
|
|
throw CancellationError()
|
|
} catch let error as ShareInboxError {
|
|
throw error
|
|
} catch {
|
|
return try await loadProviderValue(discard: { file in
|
|
removeOwnedStagingFile(at: file.ownedStagingURL)
|
|
}) { completion in
|
|
provider.loadFileRepresentation(forTypeIdentifier: typeIdentifier) { url, error in
|
|
if let error {
|
|
completion(.failure(error))
|
|
return
|
|
}
|
|
guard let url, url.isFileURL else {
|
|
completion(.failure(ShareInboxError.invalidPayload))
|
|
return
|
|
}
|
|
do {
|
|
completion(.success(try stageImage(
|
|
from: url,
|
|
suggestedName: suggestedName ?? url.lastPathComponent
|
|
)))
|
|
} catch {
|
|
completion(.failure(error))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private static func loadPDF(from provider: NSItemProvider) async throws -> SharePayloadFile {
|
|
let suggestedName = provider.suggestedName
|
|
return try await loadProviderValue(discard: { file in
|
|
removeOwnedStagingFile(at: file.ownedStagingURL)
|
|
}) { completion in
|
|
provider.loadFileRepresentation(forTypeIdentifier: UTType.pdf.identifier) { url, error in
|
|
if let error {
|
|
completion(.failure(error))
|
|
return
|
|
}
|
|
guard let url, url.isFileURL else {
|
|
completion(.failure(ShareInboxError.invalidPayload))
|
|
return
|
|
}
|
|
do {
|
|
completion(
|
|
.success(try stagePDF(
|
|
from: url,
|
|
suggestedName: suggestedName ?? url.lastPathComponent,
|
|
declaredTypeIdentifier: UTType.pdf.identifier
|
|
))
|
|
)
|
|
} catch {
|
|
completion(.failure(error))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private static func loadProviderValue<Value>(
|
|
discard: @escaping (Value) -> Void = { _ in },
|
|
operation: (@escaping (Result<Value, Error>) -> Void) -> Progress?
|
|
) async throws -> Value {
|
|
let state = ProviderLoadState<Value>(discard: discard)
|
|
return try await withTaskCancellationHandler {
|
|
try await withCheckedThrowingContinuation { continuation in
|
|
state.install(continuation)
|
|
guard !Task.isCancelled else {
|
|
state.cancel()
|
|
return
|
|
}
|
|
let progress = operation { result in
|
|
state.resume(with: result)
|
|
}
|
|
state.install(progress)
|
|
}
|
|
} onCancel: {
|
|
state.cancel()
|
|
}
|
|
}
|
|
|
|
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"
|
|
}
|
|
|
|
}
|