snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
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

This commit is contained in:
upstream-mirror
2026-09-16 04:01:20 +05:00
commit 702cc698a7
9990 changed files with 1073618 additions and 0 deletions

View File

@@ -0,0 +1,27 @@
//
// ShareInboxConstants.swift
// Shared between AFFiNE and ShareExtension
//
import Foundation
enum ShareInboxConstants {
static let appGroupId = "group.app.affine.pro"
static let workspaceModeFileName = "share-workspace-mode.json"
static let inboxDirectoryName = "ShareInbox"
static let attachmentsDirectoryName = "Attachments"
static let invalidDirectoryName = "Invalid"
static let stagingDirectoryName = "affine-share-inbox-staging"
static let officialLinkPreviewURL = URL(
string: "https://app.affine.pro/api/worker/link-preview"
)!
static let maxThumbnailBytes = 256 * 1024
static let maxShareAttachmentBytes = 64 * 1024 * 1024
static let stagingMaxAge: TimeInterval = 24 * 60 * 60
static let openInboxURL = URL(string: "affine://share-inbox")!
static var stagingDirectoryURL: URL {
FileManager.default.temporaryDirectory
.appendingPathComponent(stagingDirectoryName, isDirectory: true)
}
}

View File

@@ -0,0 +1,205 @@
import Foundation
struct ShareInboxAttachment: Codable, Equatable {
var fileName: String
var mimeType: String
var relativePath: String
}
struct ShareInboxResolvedAttachment: Equatable {
var itemId: String
var url: URL
var relativePath: String
var name: String
var mimeType: String
var size: Int
}
enum ShareInboxContentKind: String, Codable {
case url
case text
case image
case pdf
}
struct ShareInboxContent: Codable, Equatable {
var kind: ShareInboxContentKind
var url: String?
var text: String?
}
struct ShareInboxTarget: Codable, Equatable {
var workspaceId: String
var workspaceFlavour: String
var tagIds: [String]
var collectionId: String?
}
struct ShareInboxResult: Codable, Equatable {
var docId: String
var committedAt: Date
}
enum SharePreviewRoute: String, Codable {
case official
case deferred
}
enum ShareWorkspaceMode: String, Codable {
case selfHostedPresent
case cloudOnly
case signedOut
case unknown
}
struct ShareWorkspaceModeSnapshot: Codable {
var version = 1
var mode: ShareWorkspaceMode
var updatedAt = Date()
}
struct ShareInboxItem: Codable, Equatable, Identifiable {
static let currentSchemaVersion = 2
private enum CodingKeys: String, CodingKey {
case schemaVersion
case importAttemptId
case id
case documentId
case createdAt
case title
case content
case target
case previewRoute
case previewText
case attachments
case result
case lastError
}
var schemaVersion: Int
var importAttemptId: String
var id: String
var documentId: String
var createdAt: Date
var title: String
var content: ShareInboxContent
var target: ShareInboxTarget?
var previewRoute: SharePreviewRoute
var previewText: String?
var attachments: [ShareInboxAttachment]
var result: ShareInboxResult?
var lastError: String?
init(
id: String = UUID().uuidString,
documentId: String = UUID().uuidString,
importAttemptId: String = UUID().uuidString,
createdAt: Date = Date(),
title: String,
content: ShareInboxContent,
target: ShareInboxTarget? = nil,
previewRoute: SharePreviewRoute = .deferred,
previewText: String? = nil,
attachments: [ShareInboxAttachment] = [],
result: ShareInboxResult? = nil,
lastError: String? = nil
) {
schemaVersion = Self.currentSchemaVersion
self.importAttemptId = importAttemptId
self.id = id
self.documentId = documentId
self.createdAt = createdAt
self.title = title
self.content = content
self.target = target
self.previewRoute = previewRoute
self.previewText = previewText
self.attachments = attachments
self.result = result
self.lastError = lastError
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let schemaVersion = try container.decodeIfPresent(Int.self, forKey: .schemaVersion) ?? 1
self.schemaVersion = schemaVersion
if schemaVersion == 1 {
importAttemptId = UUID().uuidString
} else {
let importAttemptId = try container.decode(String.self, forKey: .importAttemptId)
guard !importAttemptId.isEmpty else {
throw DecodingError.dataCorruptedError(
forKey: .importAttemptId,
in: container,
debugDescription: "importAttemptId must not be empty"
)
}
self.importAttemptId = importAttemptId
}
id = try container.decode(String.self, forKey: .id)
documentId = try container.decode(String.self, forKey: .documentId)
createdAt = try container.decode(Date.self, forKey: .createdAt)
title = try container.decode(String.self, forKey: .title)
content = try container.decode(ShareInboxContent.self, forKey: .content)
target = try container.decodeIfPresent(ShareInboxTarget.self, forKey: .target)
previewRoute = (try? container.decode(SharePreviewRoute.self, forKey: .previewRoute)) ?? .deferred
previewText = try container.decodeIfPresent(String.self, forKey: .previewText)
attachments =
try container.decodeIfPresent([ShareInboxAttachment].self, forKey: .attachments) ?? []
result = try container.decodeIfPresent(ShareInboxResult.self, forKey: .result)
lastError = try container.decodeIfPresent(String.self, forKey: .lastError)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(Self.currentSchemaVersion, forKey: .schemaVersion)
try container.encode(importAttemptId, forKey: .importAttemptId)
try container.encode(id, forKey: .id)
try container.encode(documentId, forKey: .documentId)
try container.encode(createdAt, forKey: .createdAt)
try container.encode(title, forKey: .title)
try container.encode(content, forKey: .content)
try container.encodeIfPresent(target, forKey: .target)
try container.encode(previewRoute, forKey: .previewRoute)
try container.encodeIfPresent(previewText, forKey: .previewText)
try container.encode(attachments, forKey: .attachments)
try container.encodeIfPresent(result, forKey: .result)
try container.encodeIfPresent(lastError, forKey: .lastError)
}
}
enum ShareInboxPendingEntry: Equatable {
case ready(ShareInboxItem)
case unsupportedVersion(itemId: String, schemaVersion: Int)
var createdAt: Date {
switch self {
case let .ready(item):
item.createdAt
case .unsupportedVersion:
.distantFuture
}
}
}
struct SharePayloadFile: Equatable {
var ownedStagingURL: URL
var name: String
var mimeType: String
var size: Int
var thumbnailData: Data
}
struct SharePayloadDraft: Equatable {
var title: String
var content: ShareInboxContent?
var previewText: String
var file: SharePayloadFile?
var errorMessage: String?
func discardStagingFiles() {
guard let file else { return }
SharePayloadBuilder.removeOwnedStagingFile(at: file.ownedStagingURL)
}
}

View File

@@ -0,0 +1,108 @@
import Foundation
import UniformTypeIdentifiers
enum ShareInboxSafety {
static func manifestTitle(original: String, userEdited: String?) -> String {
(userEdited ?? original).trimmingCharacters(in: .whitespacesAndNewlines)
}
static func previewTitle(original: String, userEdited: String?, serverTitle: String?) -> String {
userEdited ?? serverTitle ?? original
}
static func normalizedManifestID(_ value: String) -> String? {
UUID(uuidString: value)?.uuidString
}
static func manifestSchemaVersion(from data: Data) -> Int? {
guard let manifest = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return nil
}
guard let schemaVersion = manifest["schemaVersion"] else { return 1 }
return schemaVersion as? Int
}
static func normalizedWebURL(_ value: String) -> String? {
guard
let components = URLComponents(
string: value.trimmingCharacters(in: .whitespacesAndNewlines)
),
let scheme = components.scheme?.lowercased(),
scheme == "http" || scheme == "https",
components.host?.isEmpty == false,
components.user == nil,
components.password == nil,
let url = components.url
else {
return nil
}
return url.absoluteString
}
static func previewRoute(url: String, mode: ShareWorkspaceMode) -> SharePreviewRoute {
guard normalizedWebURL(url) != nil else { return .deferred }
if isOfficialPreviewURL(url) || mode == .cloudOnly || mode == .signedOut {
return .official
}
return .deferred
}
static func isOfficialPreviewURL(_ value: String) -> Bool {
guard let normalized = normalizedWebURL(value), let url = URL(string: normalized) else {
return false
}
let host = url.host?.lowercased()
let components = url.pathComponents.filter { $0 != "/" }
if host == "youtu.be" {
return components.count == 1 && !components[0].isEmpty
}
if ["youtube.com", "www.youtube.com", "m.youtube.com"].contains(host) {
if url.path == "/watch" {
return !(URLComponents(url: url, resolvingAgainstBaseURL: false)?
.queryItems?.first(where: { $0.name == "v" })?.value?.isEmpty ?? true)
}
return components.count == 2
&& ["shorts", "live", "embed"].contains(components[0])
&& !components[1].isEmpty
}
if ["x.com", "www.x.com", "twitter.com", "www.twitter.com"].contains(host) {
return components.count == 3 && components[1] == "status"
&& !components[2].isEmpty && components[2].allSatisfy { $0.isASCII && $0.isNumber }
}
return false
}
static func detectRasterImageMimeType(_ data: Data) -> String? {
let bytes = [UInt8](data.prefix(12))
if bytes.starts(with: [0xFF, 0xD8, 0xFF]) {
return "image/jpeg"
}
if bytes.starts(with: [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) {
return "image/png"
}
if bytes.starts(with: Array("GIF87a".utf8)) || bytes.starts(with: Array("GIF89a".utf8)) {
return "image/gif"
}
if bytes.count >= 12,
Array(bytes[0 ..< 4]) == Array("RIFF".utf8),
Array(bytes[8 ..< 12]) == Array("WEBP".utf8)
{
return "image/webp"
}
if bytes.count >= 12, Array(bytes[4 ..< 8]) == Array("ftyp".utf8) {
let brand = String(decoding: bytes[8 ..< 12], as: UTF8.self).lowercased()
if ["heic", "heix", "hevc", "hevx", "mif1", "msf1"].contains(brand) {
return "image/heic"
}
}
return nil
}
static func isPDFTypeIdentifier(_ value: String) -> Bool {
UTType(value)?.conforms(to: .pdf) == true
}
static func detectPDFMimeType(_ data: Data) -> String? {
data.starts(with: Data("%PDF-".utf8)) ? "application/pdf" : nil
}
}

View File

@@ -0,0 +1,480 @@
//
// ShareInboxStore.swift
// Shared between AFFiNE and ShareExtension
//
import Foundation
final class ShareInboxStore {
typealias DataWriter = (Data, URL, Data.WritingOptions) throws -> Void
typealias AttachmentFileCopy = (URL, URL) throws -> Void
typealias ItemRemover = (URL) throws -> Void
static let shared = ShareInboxStore()
private let fileManager: FileManager
private let configuredContainerURL: URL?
private let writeData: DataWriter
private let copyFile: AttachmentFileCopy
private let removeItem: ItemRemover
private let encoder: JSONEncoder = {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
return encoder
}()
private let decoder: JSONDecoder = {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return decoder
}()
init(
fileManager: FileManager = .default,
containerURL: URL? = nil,
writeData: DataWriter? = nil,
copyFile: AttachmentFileCopy? = nil,
removeItem: ItemRemover? = nil
) {
self.fileManager = fileManager
configuredContainerURL = containerURL
self.writeData = writeData ?? Self.writeAtomically
self.copyFile = copyFile ?? ShareInboxFileCopy.copyCoordinatedFile
self.removeItem = removeItem ?? { try fileManager.removeItem(at: $0) }
}
var containerURL: URL? {
configuredContainerURL
?? fileManager.containerURL(forSecurityApplicationGroupIdentifier: ShareInboxConstants.appGroupId)
}
private var inboxDirectoryURL: URL? {
guard let containerURL else { return nil }
return containerURL
.appendingPathComponent(ShareInboxConstants.inboxDirectoryName, isDirectory: true)
}
private var attachmentsDirectoryURL: URL? {
guard let inboxDirectoryURL else { return nil }
return inboxDirectoryURL
.appendingPathComponent(ShareInboxConstants.attachmentsDirectoryName, isDirectory: true)
}
private var invalidDirectoryURL: URL? {
guard let inboxDirectoryURL else { return nil }
return inboxDirectoryURL
.appendingPathComponent(ShareInboxConstants.invalidDirectoryName, isDirectory: true)
}
@discardableResult
func ensureDirectories() -> Bool {
guard let inboxDirectoryURL, let attachmentsDirectoryURL, let invalidDirectoryURL else {
return false
}
do {
try fileManager.createDirectory(at: inboxDirectoryURL, withIntermediateDirectories: true)
try fileManager.createDirectory(at: attachmentsDirectoryURL, withIntermediateDirectories: true)
try fileManager.createDirectory(at: invalidDirectoryURL, withIntermediateDirectories: true)
removeStaleAttachmentDirectories()
return true
} catch {
return false
}
}
func enqueue(
_ item: ShareInboxItem,
attachmentFiles: [(ShareInboxAttachment, URL)] = []
) throws {
guard ensureDirectories() else {
throw ShareInboxError.containerUnavailable
}
guard let fileURL = manifestURL(for: item.id) else {
throw ShareInboxError.invalidPayload
}
try ensureManifestCanMutate(at: fileURL)
guard attachmentFiles.count == item.attachments.count else {
throw ShareInboxError.invalidPayload
}
let attachmentDirectory = attachmentsDirectoryURL?.appendingPathComponent(item.id, isDirectory: true)
let temporaryAttachmentDirectory = attachmentsDirectoryURL?
.appendingPathComponent(".\(item.id).tmp", isDirectory: true)
if !attachmentFiles.isEmpty,
attachmentDirectory == nil || temporaryAttachmentDirectory == nil
{
throw ShareInboxError.containerUnavailable
}
var publishedAttachmentDirectory: URL?
do {
if !attachmentFiles.isEmpty,
let attachmentDirectory,
let temporaryAttachmentDirectory
{
guard !fileManager.fileExists(atPath: attachmentDirectory.path),
!fileManager.fileExists(atPath: temporaryAttachmentDirectory.path)
else {
throw ShareInboxError.invalidPayload
}
try fileManager.createDirectory(at: temporaryAttachmentDirectory, withIntermediateDirectories: false)
for (index, entry) in attachmentFiles.enumerated() {
let (attachment, sourceURL) = entry
guard attachment == item.attachments[index],
isValidAttachment(attachment, for: item),
fileManager.fileExists(atPath: sourceURL.path)
else {
throw ShareInboxError.invalidPayload
}
let destination = temporaryAttachmentDirectory.appendingPathComponent(attachment.fileName)
try copyFile(sourceURL, destination)
}
try fileManager.moveItem(at: temporaryAttachmentDirectory, to: attachmentDirectory)
publishedAttachmentDirectory = attachmentDirectory
}
let data = try encoder.encode(item)
try writeData(data, fileURL, .atomic)
} catch {
if let temporaryAttachmentDirectory {
try? fileManager.removeItem(at: temporaryAttachmentDirectory)
}
if let publishedAttachmentDirectory {
try? fileManager.removeItem(at: publishedAttachmentDirectory)
}
throw error
}
}
private func isValidAttachment(_ attachment: ShareInboxAttachment, for item: ShareInboxItem) -> Bool {
guard !attachment.fileName.isEmpty,
attachment.fileName == (attachment.fileName as NSString).lastPathComponent,
attachment.fileName != ".",
attachment.fileName != "..",
attachment.relativePath == "\(item.id)/\(attachment.fileName)",
attachmentURL(for: attachment) != nil
else {
return false
}
return true
}
func update(_ item: ShareInboxItem) throws {
guard ensureDirectories(), let fileURL = manifestURL(for: item.id) else {
throw ShareInboxError.containerUnavailable
}
try ensureManifestCanMutate(at: fileURL)
try writeData(encoder.encode(item), fileURL, .atomic)
}
func updateWorkspaceMode(_ mode: ShareWorkspaceMode) throws {
guard let containerURL else { throw ShareInboxError.containerUnavailable }
try writeData(encoder.encode(ShareWorkspaceModeSnapshot(mode: mode)),
containerURL.appendingPathComponent(ShareInboxConstants.workspaceModeFileName), .atomic)
}
func workspaceMode() -> ShareWorkspaceMode {
guard let containerURL,
let data = try? Data(contentsOf: containerURL.appendingPathComponent(ShareInboxConstants.workspaceModeFileName)),
let snapshot = try? decoder.decode(ShareWorkspaceModeSnapshot.self, from: data),
snapshot.version == 1
else { return .unknown }
return snapshot.mode
}
func pendingItems() -> [ShareInboxPendingEntry] {
readPendingItems().compactMap { entry in
guard case let .ready(item) = entry, item.result != nil else {
return entry
}
try? remove(item)
return nil
}
}
private func readPendingItems() -> [ShareInboxPendingEntry] {
guard ensureDirectories(), let inboxDirectoryURL else { return [] }
guard let urls = try? fileManager.contentsOfDirectory(
at: inboxDirectoryURL,
includingPropertiesForKeys: [.contentModificationDateKey],
options: [.skipsHiddenFiles]
) else {
return []
}
return urls
.filter { $0.pathExtension.lowercased() == "json" }
.compactMap { url -> ShareInboxPendingEntry? in
guard let data = try? Data(contentsOf: url) else {
quarantine(url)
return nil
}
guard let schemaVersion = ShareInboxSafety.manifestSchemaVersion(from: data) else {
quarantine(url)
return nil
}
if schemaVersion > ShareInboxItem.currentSchemaVersion {
return .unsupportedVersion(
itemId: url.deletingPathExtension().lastPathComponent,
schemaVersion: schemaVersion
)
}
guard ShareInboxSafety.normalizedManifestID(url.deletingPathExtension().lastPathComponent) != nil
else {
quarantine(url)
return nil
}
guard var item = try? decoder.decode(ShareInboxItem.self, from: data),
let expectedURL = manifestURL(for: item.id),
expectedURL.lastPathComponent.caseInsensitiveCompare(url.lastPathComponent) == .orderedSame
else {
quarantine(url)
return nil
}
if item.schemaVersion < ShareInboxItem.currentSchemaVersion {
item.schemaVersion = ShareInboxItem.currentSchemaVersion
guard (try? update(item)) != nil else { return nil }
}
return .ready(item)
}
.sorted { $0.createdAt < $1.createdAt }
}
func complete(itemId: String, docId: String, committedAt: Date) throws {
guard !docId.isEmpty,
let item = readPendingItems().compactMap({ entry -> ShareInboxItem? in
guard case let .ready(item) = entry else { return nil }
return item
}).first(where: { $0.id == itemId }),
item.documentId == docId
else {
throw ShareInboxError.invalidPayload
}
if let result = item.result {
guard result.docId == docId else {
throw ShareInboxError.invalidPayload
}
try remove(item)
return
}
var completedItem = item
completedItem.result = ShareInboxResult(
docId: docId,
committedAt: committedAt
)
completedItem.lastError = nil
try update(completedItem)
try remove(completedItem)
}
func attachmentURL(for attachment: ShareInboxAttachment) -> URL? {
guard let attachmentsDirectoryURL else { return nil }
guard !attachment.relativePath.isEmpty,
!attachment.relativePath.hasPrefix("/"),
!attachment.relativePath.split(separator: "/").contains("..")
else {
return nil
}
let base = attachmentsDirectoryURL.standardizedFileURL
let candidate = base.appendingPathComponent(attachment.relativePath).standardizedFileURL
let resolvedBase = base.resolvingSymlinksInPath().standardizedFileURL
let resolvedCandidate = candidate.resolvingSymlinksInPath().standardizedFileURL
guard candidate.path.hasPrefix(base.path + "/"),
resolvedCandidate.path.hasPrefix(resolvedBase.path + "/")
else { return nil }
return candidate
}
func resolveAttachment(for item: ShareInboxItem) -> ShareInboxResolvedAttachment? {
guard item.attachments.count == 1,
let attachment = item.attachments.first,
isValidAttachment(attachment, for: item),
let url = attachmentURL(for: attachment),
fileManager.fileExists(atPath: url.path),
let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey, .isSymbolicLinkKey]),
values.isSymbolicLink != true,
values.isRegularFile == true,
let size = values.fileSize,
size > 0
else {
return nil
}
let prefix: Data
do {
prefix = try ShareInboxFileCopy.readPrefix(from: url)
} catch {
return nil
}
switch item.content.kind {
case .image:
guard size <= 12 * 1024 * 1024,
ShareInboxSafety.detectRasterImageMimeType(prefix) == attachment.mimeType
else { return nil }
case .pdf:
guard size <= ShareInboxConstants.maxShareAttachmentBytes,
ShareInboxSafety.detectPDFMimeType(prefix) == attachment.mimeType
else { return nil }
case .url, .text:
return nil
}
return ShareInboxResolvedAttachment(
itemId: item.id,
url: url,
relativePath: attachment.relativePath,
name: attachment.fileName,
mimeType: attachment.mimeType,
size: size
)
}
func remove(_ item: ShareInboxItem) throws {
guard let fileURL = manifestURL(for: item.id) else {
throw ShareInboxError.invalidPayload
}
try ensureManifestCanMutate(at: fileURL)
if let attachmentDirectory = attachmentDirectoryURL(for: item.id),
fileManager.fileExists(atPath: attachmentDirectory.path)
{
try removeItem(attachmentDirectory)
}
if fileManager.fileExists(atPath: fileURL.path) {
try removeItem(fileURL)
}
}
private func attachmentDirectoryURL(for itemId: String) -> URL? {
guard let attachmentsDirectoryURL,
ShareInboxSafety.normalizedManifestID(itemId) != nil
else {
return nil
}
let base = attachmentsDirectoryURL.standardizedFileURL
let candidate = base
.appendingPathComponent(itemId, isDirectory: true)
.standardizedFileURL
guard candidate.path.hasPrefix(base.path + "/") else { return nil }
return candidate
}
private func manifestURL(for itemId: String) -> URL? {
guard let inboxDirectoryURL,
let normalizedId = ShareInboxSafety.normalizedManifestID(itemId)
else {
return nil
}
let base = inboxDirectoryURL.standardizedFileURL
let candidate = base
.appendingPathComponent("\(normalizedId).json")
.standardizedFileURL
guard candidate.path.hasPrefix(base.path + "/") else { return nil }
return candidate
}
private func quarantine(_ url: URL) {
guard let invalidDirectoryURL else { return }
let destination = invalidDirectoryURL.appendingPathComponent(url.lastPathComponent)
try? fileManager.removeItem(at: destination)
try? fileManager.moveItem(at: url, to: destination)
}
private func removeStaleAttachmentDirectories() {
guard let attachmentsDirectoryURL,
let urls = try? fileManager.contentsOfDirectory(
at: attachmentsDirectoryURL,
includingPropertiesForKeys: [.contentModificationDateKey, .isDirectoryKey],
options: []
)
else { return }
let staleBefore = Date.now.addingTimeInterval(-ShareInboxConstants.stagingMaxAge)
for url in urls {
guard let values = try? url.resourceValues(
forKeys: [.contentModificationDateKey, .isDirectoryKey]
),
values.isDirectory == true,
let modifiedAt = values.contentModificationDate,
modifiedAt < staleBefore
else { continue }
let name = url.lastPathComponent
let isTemporary = name.hasPrefix(".") && name.hasSuffix(".tmp")
let isPublishedOrphan = ShareInboxSafety.normalizedManifestID(name) != nil
&& manifestURL(for: name).map { !fileManager.fileExists(atPath: $0.path) } == true
if isTemporary || isPublishedOrphan {
try? removeItem(url)
}
}
}
private static func writeAtomically(
_ data: Data,
_ url: URL,
_ options: Data.WritingOptions
) throws {
try data.write(to: url, options: options)
}
private func ensureManifestCanMutate(at url: URL) throws {
guard fileManager.fileExists(atPath: url.path) else { return }
let data = try Data(contentsOf: url)
guard let schemaVersion = ShareInboxSafety.manifestSchemaVersion(from: data),
schemaVersion > ShareInboxItem.currentSchemaVersion
else { return }
throw ShareInboxError.unsupportedVersion
}
}
enum ShareInboxError: Error {
case containerUnavailable
case invalidPayload
case payloadTooLarge
case unsupportedVersion
}
enum ShareInboxFileCopy {
private static let chunkSize = 64 * 1024
static func copyCoordinatedFile(from sourceURL: URL, to destinationURL: URL) throws {
try withCoordinatedRead(sourceURL) { source in
try copyChunkedFile(from: source, to: destinationURL)
}
}
static func withCoordinatedRead(
_ sourceURL: URL,
_ read: (URL) throws -> Void
) throws {
var coordinationError: NSError?
var readError: Error?
let coordinator = NSFileCoordinator()
coordinator.coordinate(readingItemAt: sourceURL, options: [], error: &coordinationError) { source in
do {
try read(source)
} catch {
readError = error
}
}
if let coordinationError { throw coordinationError }
if let readError { throw readError }
}
static func readPrefix(from url: URL, count: Int = 12) throws -> Data {
let handle = try FileHandle(forReadingFrom: url)
defer { try? handle.close() }
return try handle.read(upToCount: count) ?? Data()
}
static func copyChunkedFile(from sourceURL: URL, to destinationURL: URL) throws {
guard FileManager.default.createFile(atPath: destinationURL.path, contents: nil) else {
throw ShareInboxError.invalidPayload
}
let input = try FileHandle(forReadingFrom: sourceURL)
defer { try? input.close() }
let output = try FileHandle(forWritingTo: destinationURL)
defer { try? output.close() }
while let data = try input.read(upToCount: chunkSize), !data.isEmpty {
try output.write(contentsOf: data)
}
try output.synchronize()
}
}

View File

@@ -0,0 +1,242 @@
import Foundation
import ImageIO
import UIKit
struct ShareLinkPreview: Decodable, Equatable {
struct Author: Decodable, Equatable {
var name: String
var handle: String?
var avatar: String?
}
struct Transcript: Decodable, Equatable {
struct Segment: Decodable, Equatable {
var text: String
var startSeconds: Double?
var durationSeconds: Double?
var speaker: String?
}
struct Chapter: Decodable, Equatable {
var title: String
var startSeconds: Double
}
var language: String?
var segments: [Segment]
var chapters: [Chapter]?
var truncated: Bool?
}
var url: String
var title: String?
var siteName: String?
var description: String?
var images: [String]?
var favicons: [String]?
var mediaType: String?
var provider: String?
var author: Author?
var publishedAt: String?
var durationSeconds: Double?
var transcript: Transcript?
private enum CodingKeys: String, CodingKey {
case url, title, siteName, description, images, favicons, mediaType, provider
case author, publishedAt, durationSeconds, transcript
}
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
let rawURL = try values.decode(String.self, forKey: .url)
guard let normalized = ShareInboxSafety.normalizedWebURL(rawURL) else {
throw DecodingError.dataCorruptedError(forKey: .url, in: values, debugDescription: "Invalid preview URL")
}
url = normalized
func text(_ key: CodingKeys, limit: Int) -> String? {
guard let value = try? values.decode(String.self, forKey: key),
let text = SharePayloadBuilder.nonEmpty(value) else { return nil }
return String(text.prefix(limit))
}
title = text(.title, limit: 120)
siteName = text(.siteName, limit: 80)
description = text(.description, limit: 500)
mediaType = text(.mediaType, limit: 80)
provider = text(.provider, limit: 80)
publishedAt = text(.publishedAt, limit: 80)
images = (try? values.decode([String].self, forKey: .images))?
.compactMap(ShareInboxSafety.normalizedWebURL).prefix(1).map { $0 }
favicons = (try? values.decode([String].self, forKey: .favicons))?
.compactMap(ShareInboxSafety.normalizedWebURL).prefix(1).map { $0 }
author = try? values.decode(Author.self, forKey: .author)
if let name = author?.name { author?.name = String(name.prefix(80)) }
durationSeconds = try? values.decode(Double.self, forKey: .durationSeconds)
if let duration = durationSeconds, !duration.isFinite || duration < 0 { durationSeconds = nil }
if let source = try? values.decode(Transcript.self, forKey: .transcript), let excerpt = source.previewText {
transcript = Transcript(language: source.language, segments: [Transcript.Segment(text: excerpt)], chapters: nil, truncated: nil)
}
}
}
extension ShareLinkPreview.Transcript {
var previewText: String? {
var text = ""
for segment in segments {
let part = String(segment.text.prefix(241)).split(whereSeparator: \.isWhitespace).joined(separator: " ")
if part.isEmpty { continue }
if !text.isEmpty { text += " " }
text += part
if text.count > 240 { return String(text.prefix(240)) + "…" }
}
return text.isEmpty ? nil : text
}
}
enum ShareLinkPreviewState: Equatable {
case idle
case loading
case loaded(ShareLinkPreview)
case failed
}
struct ShareLinkPreviewClient {
private let session: URLSession
private let appVersion: String
init(session: URLSession? = nil, appVersion: String? = nil) {
self.appVersion = appVersion ?? Self.bundledAppVersion
if let session {
self.session = session
} else {
let configuration = URLSessionConfiguration.ephemeral
configuration.timeoutIntervalForRequest = 4
configuration.timeoutIntervalForResource = 6
configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
configuration.urlCache = nil
self.session = URLSession(configuration: configuration)
}
}
func fetch(url: String) async throws -> ShareLinkPreview {
guard let normalized = ShareInboxSafety.normalizedWebURL(url) else {
throw URLError(.badURL)
}
var request = URLRequest(url: ShareInboxConstants.officialLinkPreviewURL)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
addClientHeaders(to: &request)
request.httpBody = try JSONEncoder().encode(
Request(url: normalized, include: ["transcript"])
)
let (data, response) = try await read(request, maxBytes: 1024 * 1024)
guard let response = response as? HTTPURLResponse, response.statusCode == 200 else {
throw URLError(.badServerResponse)
}
return try JSONDecoder().decode(ShareLinkPreview.self, from: data)
}
func fetchImage(url value: String) async throws -> UIImage {
guard let candidate = URL(
string: value,
relativeTo: ShareInboxConstants.officialLinkPreviewURL
) else {
throw URLError(.badURL)
}
let resolved = candidate.absoluteURL
guard
let normalized = ShareInboxSafety.normalizedWebURL(resolved.absoluteString),
let url = URL(string: normalized)
else {
throw URLError(.badURL)
}
guard url.scheme == "https",
url.host == ShareInboxConstants.officialLinkPreviewURL.host,
url.port == ShareInboxConstants.officialLinkPreviewURL.port,
url.path == "/api/worker/image-proxy"
else { throw URLError(.badURL) }
var request = URLRequest(
url: url,
cachePolicy: .reloadIgnoringLocalCacheData,
timeoutInterval: 3
)
addClientHeaders(to: &request)
let (data, response) = try await read(request, maxBytes: 2 * 1024 * 1024)
guard let response = response as? HTTPURLResponse, response.statusCode == 200 else {
throw URLError(.badServerResponse)
}
guard let source = CGImageSourceCreateWithData(data as CFData, nil),
let thumbnail = CGImageSourceCreateThumbnailAtIndex(source, 0, [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: 480,
] as CFDictionary)
else {
throw URLError(.cannotDecodeContentData)
}
return UIImage(cgImage: thumbnail)
}
func fetchImageIfPresent(url: String?) async -> UIImage? {
guard let url else { return nil }
return try? await fetchImage(url: url)
}
private final class NoRedirect: NSObject, URLSessionTaskDelegate {
func urlSession(_: URLSession, task _: URLSessionTask,
willPerformHTTPRedirection _: HTTPURLResponse, newRequest _: URLRequest,
completionHandler: @escaping (URLRequest?) -> Void)
{
completionHandler(nil)
}
}
private func read(_ request: URLRequest, maxBytes: Int) async throws -> (Data, URLResponse) {
let (bytes, response) = try await session.bytes(for: request, delegate: NoRedirect())
defer { bytes.task.cancel() }
guard response.expectedContentLength <= maxBytes else { throw URLError(.dataLengthExceedsMaximum) }
var data = Data()
var buffer = [UInt8]()
let chunkSize = 16 * 1024
buffer.reserveCapacity(chunkSize)
for try await byte in bytes {
guard data.count + buffer.count < maxBytes else { throw URLError(.dataLengthExceedsMaximum) }
buffer.append(byte)
if buffer.count == chunkSize {
data.append(contentsOf: buffer)
buffer.removeAll(keepingCapacity: true)
}
}
data.append(contentsOf: buffer)
return (data, response)
}
private func addClientHeaders(to request: inout URLRequest) {
request.setValue("AFFiNE/\(appVersion)", forHTTPHeaderField: "User-Agent")
request.setValue(appVersion, forHTTPHeaderField: "x-affine-version")
}
private struct Request: Encodable {
var url: String
var include: [String]
}
private struct AppConfig: Decodable {
var affineVersion: String
}
private static var bundledAppVersion: String {
if let url = Bundle.main.url(forResource: "capacitor.config", withExtension: "json"),
let data = try? Data(contentsOf: url),
let version = try? JSONDecoder().decode(AppConfig.self, from: data).affineVersion,
!version.isEmpty
{
return version
}
if let version = Bundle.main.object(
forInfoDictionaryKey: "CFBundleShortVersionString"
) as? String, !version.isEmpty {
return version
}
return "0.2"
}
}

View File

@@ -0,0 +1,518 @@
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"
}
}

View File

@@ -0,0 +1,273 @@
import Foundation
import PDFKit
import UIKit
extension SharePayloadBuilder {
static func stageImage(
from sourceURL: URL,
suggestedName: String?,
copyFile: @escaping FileCopy = ShareInboxFileCopy.copyChunkedFile,
coordinatedRead: @escaping CoordinatedRead = ShareInboxFileCopy.withCoordinatedRead
) throws -> SharePayloadFile {
removeStaleStagingDirectories()
let didAccessSecurityScopedResource = sourceURL.startAccessingSecurityScopedResource()
defer {
if didAccessSecurityScopedResource {
sourceURL.stopAccessingSecurityScopedResource()
}
}
var stagedFile: SharePayloadFile?
try coordinatedRead(sourceURL) { coordinatedURL in
let values = try coordinatedURL.resourceValues(forKeys: [.fileSizeKey])
guard let size = values.fileSize, size <= maxImageBytes else {
throw ShareInboxError.payloadTooLarge
}
guard let mimeType = ShareInboxSafety.detectRasterImageMimeType(
try ShareInboxFileCopy.readPrefix(from: coordinatedURL)
) else {
throw ShareInboxError.invalidPayload
}
stagedFile = try stageImage(
name: normalizedFileName(suggestedName ?? coordinatedURL.lastPathComponent, mimeType: mimeType),
mimeType: mimeType,
size: size,
write: { destination in try copyFile(coordinatedURL, destination) }
)
}
guard let stagedFile else { throw ShareInboxError.invalidPayload }
return stagedFile
}
static func stagePDF(
from sourceURL: URL,
suggestedName: String?,
declaredTypeIdentifier: String,
copyFile: @escaping FileCopy = ShareInboxFileCopy.copyChunkedFile,
coordinatedRead: @escaping CoordinatedRead = ShareInboxFileCopy.withCoordinatedRead,
renderThumbnail: @escaping (URL) throws -> Data = pdfThumbnailData
) throws -> SharePayloadFile {
guard ShareInboxSafety.isPDFTypeIdentifier(declaredTypeIdentifier) else {
throw ShareInboxError.invalidPayload
}
removeStaleStagingDirectories()
let didAccessSecurityScopedResource = sourceURL.startAccessingSecurityScopedResource()
defer {
if didAccessSecurityScopedResource {
sourceURL.stopAccessingSecurityScopedResource()
}
}
var stagedFile: SharePayloadFile?
try coordinatedRead(sourceURL) { coordinatedURL in
let values = try coordinatedURL.resourceValues(forKeys: [.fileSizeKey])
guard let size = values.fileSize, size > 0 else {
throw ShareInboxError.invalidPayload
}
guard size <= ShareInboxConstants.maxShareAttachmentBytes else {
throw ShareInboxError.payloadTooLarge
}
guard ShareInboxSafety.detectPDFMimeType(try ShareInboxFileCopy.readPrefix(from: coordinatedURL))
== "application/pdf"
else { throw ShareInboxError.invalidPayload }
stagedFile = try stagePDF(
name: normalizedPDFFileName(suggestedName ?? coordinatedURL.lastPathComponent),
size: size,
write: { destination in try copyFile(coordinatedURL, destination) },
renderThumbnail: renderThumbnail
)
}
guard let stagedFile else { throw ShareInboxError.invalidPayload }
return stagedFile
}
static func removeOwnedStagingFile(at url: URL) {
let directory = url.deletingLastPathComponent().standardizedFileURL
let root = ShareInboxConstants.stagingDirectoryURL.standardizedFileURL
guard directory.deletingLastPathComponent() == root,
directory.lastPathComponent.hasPrefix(".") == false
else {
return
}
try? FileManager.default.removeItem(at: directory)
}
static func stageImage(
name: String,
mimeType: String,
size: Int,
write: (URL) throws -> Void
) throws -> SharePayloadFile {
let fileManager = FileManager.default
let root = ShareInboxConstants.stagingDirectoryURL
try fileManager.createDirectory(at: root, withIntermediateDirectories: true)
let identifier = UUID().uuidString
let temporaryDirectory = root.appendingPathComponent(".\(identifier).tmp", isDirectory: true)
let finalDirectory = root.appendingPathComponent(identifier, isDirectory: true)
var didPublish = false
defer {
if !didPublish {
try? fileManager.removeItem(at: temporaryDirectory)
}
}
try fileManager.createDirectory(at: temporaryDirectory, withIntermediateDirectories: false)
let destination = temporaryDirectory.appendingPathComponent(name)
try write(destination)
guard let detectedMimeType = ShareInboxSafety.detectRasterImageMimeType(
try ShareInboxFileCopy.readPrefix(from: destination)
), detectedMimeType == mimeType
else {
throw ShareInboxError.invalidPayload
}
let actualSize = try destination.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? 0
guard actualSize == size, actualSize <= maxImageBytes else {
throw ShareInboxError.payloadTooLarge
}
try fileManager.moveItem(at: temporaryDirectory, to: finalDirectory)
didPublish = true
let ownedStagingURL = finalDirectory.appendingPathComponent(name)
return SharePayloadFile(
ownedStagingURL: ownedStagingURL,
name: name,
mimeType: mimeType,
size: actualSize,
thumbnailData: thumbnailData(for: ownedStagingURL)
)
}
static func stagePDF(
name: String,
size: Int,
write: (URL) throws -> Void,
renderThumbnail: (URL) throws -> Data
) throws -> SharePayloadFile {
let fileManager = FileManager.default
let root = ShareInboxConstants.stagingDirectoryURL
try fileManager.createDirectory(at: root, withIntermediateDirectories: true)
let identifier = UUID().uuidString
let temporaryDirectory = root.appendingPathComponent(".\(identifier).tmp", isDirectory: true)
let finalDirectory = root.appendingPathComponent(identifier, isDirectory: true)
var didPublish = false
defer {
if !didPublish {
try? fileManager.removeItem(at: temporaryDirectory)
}
}
try fileManager.createDirectory(at: temporaryDirectory, withIntermediateDirectories: false)
let destination = temporaryDirectory.appendingPathComponent(name)
try write(destination)
let actualSize = try destination.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? 0
guard actualSize == size, actualSize > 0 else {
throw ShareInboxError.invalidPayload
}
guard actualSize <= ShareInboxConstants.maxShareAttachmentBytes else {
throw ShareInboxError.payloadTooLarge
}
guard ShareInboxSafety.detectPDFMimeType(try ShareInboxFileCopy.readPrefix(from: destination))
== "application/pdf"
else { throw ShareInboxError.invalidPayload }
try fileManager.moveItem(at: temporaryDirectory, to: finalDirectory)
didPublish = true
let ownedStagingURL = finalDirectory.appendingPathComponent(name)
let thumbnail = (try? renderThumbnail(ownedStagingURL)) ?? Data()
return SharePayloadFile(
ownedStagingURL: ownedStagingURL,
name: name,
mimeType: "application/pdf",
size: actualSize,
thumbnailData: thumbnail.count <= ShareInboxConstants.maxThumbnailBytes ? thumbnail : Data()
)
}
static func removeStaleStagingDirectories(now: Date = .now) {
let fileManager = FileManager.default
let root = ShareInboxConstants.stagingDirectoryURL
try? fileManager.createDirectory(at: root, withIntermediateDirectories: true)
guard let directories = try? fileManager.contentsOfDirectory(
at: root,
includingPropertiesForKeys: [.contentModificationDateKey, .isDirectoryKey],
options: []
) else {
return
}
for directory in directories {
let values = try? directory.resourceValues(forKeys: [.contentModificationDateKey, .isDirectoryKey])
guard values?.isDirectory == true,
let modifiedAt = values?.contentModificationDate,
now.timeIntervalSince(modifiedAt) > ShareInboxConstants.stagingMaxAge
else {
continue
}
try? fileManager.removeItem(at: directory)
}
}
private static func thumbnailData(for url: URL) -> Data {
guard let image = UIImage(contentsOfFile: url.path),
let thumbnail = image.preparingThumbnail(of: CGSize(width: 480, height: 480))
else {
return (try? ShareInboxFileCopy.readPrefix(
from: url,
count: ShareInboxConstants.maxThumbnailBytes
)) ?? Data()
}
var quality: CGFloat = 0.8
while quality >= 0.2 {
if let data = thumbnail.jpegData(compressionQuality: quality),
data.count <= ShareInboxConstants.maxThumbnailBytes
{
return data
}
quality -= 0.15
}
return Data()
}
private static func pdfThumbnailData(for url: URL) throws -> Data {
guard let document = PDFDocument(url: url),
let page = document.page(at: 0)
else {
throw ShareInboxError.invalidPayload
}
let thumbnail = page.thumbnail(
of: CGSize(width: 480, height: 480),
for: .mediaBox
)
var quality: CGFloat = 0.8
while quality >= 0.2 {
if let data = thumbnail.jpegData(compressionQuality: quality),
data.count <= ShareInboxConstants.maxThumbnailBytes
{
return data
}
quality -= 0.15
}
return Data()
}
private static func normalizedFileName(_ value: String, mimeType: String) -> String {
let fileExtension = fileExtension(for: mimeType)
let baseName = nonEmpty((value as NSString).lastPathComponent)
.map { ($0 as NSString).lastPathComponent }
.flatMap { nonEmpty(($0 as NSString).deletingPathExtension) }
?? "shared-image"
return "\(baseName).\(fileExtension)"
}
private static func normalizedPDFFileName(_ value: String) -> String {
let baseName = nonEmpty((value as NSString).lastPathComponent)
.flatMap { nonEmpty(($0 as NSString).deletingPathExtension) }
?? "shared-document"
return "\(baseName).pdf"
}
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"
}
}
}