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
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user