~18 min · beyond-the-app, share-extension, nsitemprovider, activation-rule, uniform-type-identifiers
Level 0번들 열어본 사람
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"확장은 어느 방향으로도 조용히 실패할 수 있으면 안 돼."
공유가 사라질 수 있는 네 곳
공유 확장은 다른 앱의 사진, PDF, 링크가 담기가 되는 길이야. 단계는 네 개인데, 가족은 그 모든 단계에서 공유를 잃어봤어. 그것도 매번 눈에 보이는 에러 없이. 시스템이 그 내용에 확장을 제안해야 하고, 확장이 첨부마다 무엇인지 정해야 하고, 그걸 불러와야 하고, 앱이 가져갈 곳에 써야 해.
제안: 활성화 규칙
훈련 앱의 확장은 메일에서 공유한 검진 PDF에 한 번도 안 나타났어. 설치도 등록도 돼 있었어. simctl spawn … pluginkit -m -i <확장 id>가 목록에 보여줬거든. 제안만 안 된 거야. Info.plist는 사전 형태의 NSExtensionActivationRule을 썼어. 텍스트, 웹 URL 하나, 이미지 여덟 개, 파일 네 개였지. 근데 이 형태의 "파일"은 com.adobe.pdf 데이터로 넘어온 PDF까지 포함하지 않았어. 해결책은 애플 확장 가이드에 나오는 조건식 형태였어. 첨부들에 SUBQUERY와 UTI-CONFORMS-TO를 거는 방식이야. 등록은 됐는데 시트에 없으면 코드가 아니라 규칙을 의심해. 그리고 TRUEPREDICATE는 절대 내보내지 마. 애플 가이드에 따르면 확장에 그게 든 앱은 거절돼.
무엇: 모든 파일은 URL이기도 해
NSItemProvider(contentsOf:)로 재보니 사진은 public.jpeg, public.file-url, public.url을 알려. 파일로 오는 공유는 전부 public.url을 따라. 파일 URL도 URL이니까. 그래서 else if 줄에서 "링크야?"를 먼저 묻는 확장은 사진을 링크로 판단하고, 버리고, 올리기 버튼을 비활성화해. 가르는 기준은 public.file-url이야. 그게 없는 URL은 웹 링크뿐이거든. 공유 코드는 그 판단을 확인 순서가 아니라 순위로 만들어서, 어떤 호출자도 자기 조건 순서를 바꿔 이 버그를 되살릴 수 없게 해. 그리고 순위에 없다는 게 공유할 수 없다는 뜻은 아니야. zip도 앱이 받겠다고 약속한 파일일 수 있어.
불러오기: 모든 불러오기에 마감
증명 트랙에서 봤던 거야. 같은 확장이 제안되게 된 뒤에는, 기기에서 메일 PDF의 loadItem이 완료 핸들러를 끝내 안 불러서 확장이 한 시간 동안 살아 있었어. 파일은 loadFileRepresentation으로 불러와. 그리고 임시 URL은 완료 핸들러 안에서만 유효하니까 바이트도 거기서 읽어. 확장의 메모리 한도를 넘지 않게 파일은 해독하지 않고 복사해. 모든 불러오기엔 마감이 있어서, 시간을 넘기면 눈에 보이는 실패로 바뀌어.
쓰기: 실패에 성공은 없어
저장 에러를 try?로 삼키고 completeRequest를 부르는 확장은 모든 실패를 성공으로 보고해. 에러는 던지고, 이유를 보여주고, cancelRequest(withError:)로 끝내. 반대 방향의 침묵도 있어. 글쓰기 편집기의 공유는 빌드 네 개를 거치는 동안 잘 들어가고 있었는데, 시트가 그냥 닫히니까 주인은 기능이 죽었다고 믿었어. 거기선 확인 카드가 답이었어. 성공한 공유는 성공했다고 말해줘.
Code
project.yml: PDF, 이미지, 링크, 텍스트에 진짜로 제안되는 활성화 규칙·yaml
SparkShare:
type: app-extension
platform: iOS
info:
path: ShareExtension/Info.plist
properties:
NSExtension:
NSExtensionPointIdentifier: com.apple.share-services
NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).ShareViewController
NSExtensionAttributes:
# The dictionary form (NSExtensionActivationSupportsFileWithMaxCount...) was never
# offered for a PDF from Safari or Mail. A predicate says what it means.
# Never TRUEPREDICATE: Apple rejects an app whose extension contains it.
NSExtensionActivationRule: >-
SUBQUERY(extensionItems, $extensionItem,
$extensionItem.attachments.@count >= 1 AND $extensionItem.attachments.@count <= 8 AND
SUBQUERY($extensionItem.attachments, $attachment,
ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.image" OR
ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "com.adobe.pdf" OR
ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.url" OR
ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.plain-text"
).@count == $extensionItem.attachments.@count
).@count >= 1
불러오기 전에 제공자 종류를 순위로 정하기: public.file-url로 가르기·swift
import Foundation
import UniformTypeIdentifiers
enum ShareKind: Int, Comparable, Sendable {
case image, pdf, file, link, text // a RANKING: the first match wins
static func < (a: ShareKind, b: ShareKind) -> Bool { a.rawValue < b.rawValue }
}
/// Decide what a provider IS before loading anything. Every file-borne share also conforms to
/// public.url (because public.file-url does), so "is it a link?" must never be asked first.
func shareKind(ofTypes identifiers: [String]) -> ShareKind? {
let types = identifiers.compactMap(UTType.init)
func has(_ type: UTType) -> Bool { types.contains { $0.conforms(to: type) } }
let isFile = has(.fileURL)
let candidates: [ShareKind?] = [
has(.image) ? .image : nil,
has(.pdf) ? .pdf : nil,
isFile ? .file : nil, // a zip or a .docx: still a file the app may take
has(.url) && !isFile ? .link : nil, // a web link is the only url WITHOUT file-url
has(.plainText) ? .text : nil,
]
return candidates.compactMap { $0 }.min()
}
// Measured on macOS with NSItemProvider(contentsOf:) and a web link object:
// photo.jpg [public.jpeg, public.file-url, public.url] -> image
// doc.pdf [com.adobe.pdf, public.file-url, public.url] -> pdf
// archive.zip [public.zip-archive, public.file-url, public.url] -> file
// web link [public.url] -> link
SparkShare에 조건식 규칙을 넣고, 기기 없이 시뮬레이터에서 세 가지를 확인해. xcrun simctl spawn <device> pluginkit -m -i com.example.spark.mobile.share가 확장을 보여주는지, python3 -m http.server로 내준 PDF에 Safari 공유 시트가 Spark를 제안하는지, 올리기 뒤 App Group 컨테이너에 담기 폴더가 생기는지. 이어서 Bundle.main.builtInPlugInsURL로 빌드된 확장의 Info.plist를 읽고, 규칙에 com.adobe.pdf가 있고 TRUEPREDICATE는 없다는 걸 확인하는 호스트 테스트를 써.
Hint
PDF는 xcrun simctl openurl <기기> http://127.0.0.1:8000/file.pdf로 열어. 컨테이너 경로는 xcrun simctl get_app_container <기기> com.example.spark.mobile group.com.example.spark에서 나오고, 거기서 ls로 본 게 진짜 결과야.
Progress
Progress is local-only — sign in to sync across devices.