iOS Share Extension: pick the destination inside the share sheet
SwiftUI folder picker in the extension (WebDAV PROPFIND), account shared via a Keychain group, upload on a background session that outlives the extension and is finished by the app. The inbox + notification hand-off remains as the fallback (no account / 'choose later'). Shared code moves to ios/Shared. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
525504bc47
commit
afc9b90963
@@ -20,6 +20,8 @@
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>NooKeychainAccessGroup</key>
|
||||
<string>$(AppIdentifierPrefix)dev.ayushya.noo.shared</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionAttributes</key>
|
||||
|
||||
@@ -6,5 +6,9 @@
|
||||
<array>
|
||||
<string>group.dev.ayushya.noo</string>
|
||||
</array>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)dev.ayushya.noo.shared</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import SwiftUI
|
||||
|
||||
/// State behind the share sheet's folder picker.
|
||||
@MainActor
|
||||
final class ShareModel: ObservableObject {
|
||||
enum Stage: Equatable {
|
||||
case preparing
|
||||
case picking
|
||||
case uploading
|
||||
case noAccount
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
@Published var stage: Stage = .preparing
|
||||
@Published var items: [SharedItem] = []
|
||||
@Published var path = "/"
|
||||
@Published var folders: [DavFolder] = []
|
||||
@Published var isLoadingFolders = false
|
||||
@Published var folderError: String?
|
||||
|
||||
let account: SharedAccount?
|
||||
|
||||
/// Set by the view controller: what each button actually does.
|
||||
var onUpload: () -> Void = {}
|
||||
var onSaveForLater: () -> Void = {}
|
||||
var onCancel: () -> Void = {}
|
||||
|
||||
init(account: SharedAccount?) {
|
||||
self.account = account
|
||||
}
|
||||
|
||||
var currentFolderName: String {
|
||||
path == "/" ? "All files" : (path as NSString).lastPathComponent
|
||||
}
|
||||
|
||||
var fileCountText: String {
|
||||
items.count == 1 ? "1 file" : "\(items.count) files"
|
||||
}
|
||||
|
||||
func open(_ path: String) async {
|
||||
guard let account else { return }
|
||||
self.path = path
|
||||
isLoadingFolders = true
|
||||
folderError = nil
|
||||
defer { isLoadingFolders = false }
|
||||
do {
|
||||
folders = try await DavClient.listFolders(account: account, path: path)
|
||||
} catch {
|
||||
folders = []
|
||||
folderError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func openParent() async {
|
||||
let parent = (path as NSString).deletingLastPathComponent
|
||||
await open(parent.isEmpty ? "/" : parent)
|
||||
}
|
||||
}
|
||||
|
||||
struct SharePickerView: View {
|
||||
@ObservedObject var model: ShareModel
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
Divider()
|
||||
content
|
||||
Divider()
|
||||
footer
|
||||
}
|
||||
.background(Color(.systemBackground))
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack {
|
||||
Button("Cancel", action: model.onCancel)
|
||||
Spacer()
|
||||
Text("Upload to Noo").font(.headline)
|
||||
Spacer()
|
||||
// Balances Cancel so the title stays centred.
|
||||
Button("Cancel", action: {}).hidden()
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
switch model.stage {
|
||||
case .preparing:
|
||||
centered { ProgressView("Preparing \(model.items.isEmpty ? "" : model.fileCountText)…") }
|
||||
case .uploading:
|
||||
centered {
|
||||
VStack(spacing: 8) {
|
||||
ProgressView()
|
||||
Text("Uploading \(model.fileCountText) in the background").font(.subheadline)
|
||||
}
|
||||
}
|
||||
case .noAccount:
|
||||
centered {
|
||||
Text("Sign in to Noo first, then share again. You can also save these files and choose a folder from inside Noo.")
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(.secondary)
|
||||
.padding()
|
||||
}
|
||||
case .failed(let message):
|
||||
centered {
|
||||
Text(message).multilineTextAlignment(.center).foregroundColor(.secondary).padding()
|
||||
}
|
||||
case .picking:
|
||||
folderList
|
||||
}
|
||||
}
|
||||
|
||||
private var folderList: some View {
|
||||
List {
|
||||
Section(header: Text(model.account?.displayName ?? "")) {
|
||||
if model.path != "/" {
|
||||
Button {
|
||||
Task { await model.openParent() }
|
||||
} label: {
|
||||
Label("Up to \((model.path as NSString).deletingLastPathComponent == "/" ? "All files" : ((model.path as NSString).deletingLastPathComponent as NSString).lastPathComponent)", systemImage: "arrow.turn.left.up")
|
||||
}
|
||||
}
|
||||
if model.isLoadingFolders {
|
||||
HStack { Spacer(); ProgressView(); Spacer() }
|
||||
} else if let error = model.folderError {
|
||||
Text(error).foregroundColor(.secondary)
|
||||
} else if model.folders.isEmpty {
|
||||
Text("No folders here").foregroundColor(.secondary)
|
||||
} else {
|
||||
ForEach(model.folders, id: \.path) { folder in
|
||||
Button {
|
||||
Task { await model.open(folder.path) }
|
||||
} label: {
|
||||
Label(folder.name, systemImage: "folder")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
}
|
||||
|
||||
private var footer: some View {
|
||||
VStack(spacing: 8) {
|
||||
if model.stage == .picking {
|
||||
Button(action: model.onUpload) {
|
||||
Text("Upload \(model.fileCountText) to \(model.currentFolderName)")
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(model.isLoadingFolders)
|
||||
}
|
||||
if model.stage == .picking || model.stage == .noAccount {
|
||||
Button("Choose a folder later in Noo", action: model.onSaveForLater)
|
||||
.font(.subheadline)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
private func centered<Content: View>(@ViewBuilder _ content: () -> Content) -> some View {
|
||||
VStack { Spacer(); content(); Spacer() }.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
@@ -1,66 +1,79 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
import UserNotifications
|
||||
|
||||
/// "Share to Noo": copies whatever was shared into the App Group container
|
||||
/// (`SharedInbox`) and finishes. The extension can't pick a destination or
|
||||
/// upload - it has no session and a tight memory/time budget - so the app
|
||||
/// does that when it next opens, through its existing destination picker. A
|
||||
/// notification tells the user to open it, because an extension can't launch
|
||||
/// its host app.
|
||||
/// "Share to Noo". Like Reminders' or Notes' share sheet, the destination is
|
||||
/// chosen *inside* the sheet - an extension can't open its host app - so this
|
||||
/// shows a folder picker, then starts a background upload and closes:
|
||||
///
|
||||
/// 1. Copies whatever was shared into the App Group (`SharedInbox`).
|
||||
/// 2. Reads the signed-in account the app left in the shared Keychain
|
||||
/// (`SharedAccountStore`) and lists folders over WebDAV (`DavClient`).
|
||||
/// 3. "Upload" queues the files on a background session that outlives this
|
||||
/// process (`ShareUpload`); the app is relaunched to finish and notify.
|
||||
///
|
||||
/// With no account, or if the user prefers, the files are instead left in the
|
||||
/// inbox for the app to offer its own destination picker next time it opens
|
||||
/// (with a notification saying so).
|
||||
final class ShareViewController: UIViewController {
|
||||
private let label = UILabel()
|
||||
private let spinner = UIActivityIndicatorView(style: .large)
|
||||
private var model: ShareModel!
|
||||
private var batchDirectory: URL?
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .systemBackground
|
||||
label.text = "Saving to Noo…"
|
||||
label.font = .preferredFont(forTextStyle: .headline)
|
||||
label.textAlignment = .center
|
||||
label.numberOfLines = 0
|
||||
spinner.startAnimating()
|
||||
model = ShareModel(account: SharedAccountStore.load())
|
||||
model.onUpload = { [weak self] in self?.upload() }
|
||||
model.onSaveForLater = { [weak self] in self?.saveForLater() }
|
||||
model.onCancel = { [weak self] in self?.cancel() }
|
||||
|
||||
let stack = UIStackView(arrangedSubviews: [spinner, label])
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 16
|
||||
stack.alignment = .center
|
||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(stack)
|
||||
let host = UIHostingController(rootView: SharePickerView(model: model))
|
||||
addChild(host)
|
||||
host.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(host.view)
|
||||
NSLayoutConstraint.activate([
|
||||
stack.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||
stack.centerYAnchor.constraint(equalTo: view.centerYAnchor),
|
||||
stack.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor, constant: 24),
|
||||
stack.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor, constant: -24),
|
||||
host.view.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
host.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
host.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
host.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
])
|
||||
host.didMove(toParent: self)
|
||||
|
||||
Task { await importSharedItems() }
|
||||
Task { await prepare() }
|
||||
}
|
||||
|
||||
private func importSharedItems() async {
|
||||
// MARK: - Importing
|
||||
|
||||
@MainActor
|
||||
private func prepare() async {
|
||||
guard let root = AppGroup.containerURL else {
|
||||
return finish(message: "Noo isn't set up to receive files yet.", saved: 0)
|
||||
model.stage = .failed("Noo isn't set up to receive files yet.")
|
||||
return
|
||||
}
|
||||
let providers = (extensionContext?.inputItems as? [NSExtensionItem])?
|
||||
.flatMap { $0.attachments ?? [] } ?? []
|
||||
let providers =
|
||||
(extensionContext?.inputItems as? [NSExtensionItem])?.flatMap { $0.attachments ?? [] } ?? []
|
||||
|
||||
var saved: [SharedItem] = []
|
||||
do {
|
||||
let batch = try SharedInbox.makeBatchDirectory(in: root)
|
||||
batchDirectory = batch
|
||||
for provider in providers where provider.hasItemConformingToTypeIdentifier(UTType.item.identifier) {
|
||||
if let item = try? await copy(provider, into: batch) { saved.append(item) }
|
||||
if let item = try? await copy(provider, into: batch) { model.items.append(item) }
|
||||
}
|
||||
try SharedInbox.append(saved, in: root)
|
||||
} catch {
|
||||
return finish(message: "Couldn't save to Noo: \(error.localizedDescription)", saved: 0)
|
||||
model.stage = .failed("Couldn't save to Noo: \(error.localizedDescription)")
|
||||
return
|
||||
}
|
||||
|
||||
if saved.isEmpty {
|
||||
finish(message: "Noo can only receive files and photos.", saved: 0)
|
||||
} else {
|
||||
notify(count: saved.count)
|
||||
finish(message: saved.count == 1 ? "Saved 1 file to Noo" : "Saved \(saved.count) files to Noo", saved: saved.count)
|
||||
guard !model.items.isEmpty else {
|
||||
model.stage = .failed("Noo can only receive files and photos.")
|
||||
return
|
||||
}
|
||||
guard model.account != nil else {
|
||||
model.stage = .noAccount
|
||||
return
|
||||
}
|
||||
model.stage = .picking
|
||||
await model.open("/")
|
||||
}
|
||||
|
||||
/// `loadFileRepresentation`'s URL only lives for the duration of its
|
||||
@@ -72,7 +85,7 @@ final class ShareViewController: UIViewController {
|
||||
return continuation.resume(throwing: error ?? CocoaError(.fileReadUnknown))
|
||||
}
|
||||
do {
|
||||
let destination = Self.uniqueURL(in: directory, name: url.lastPathComponent)
|
||||
let destination = LocalFiles.uniqueURL(in: directory, name: url.lastPathComponent)
|
||||
try FileManager.default.copyItem(at: url, to: destination)
|
||||
let size = (try? FileManager.default.attributesOfItem(atPath: destination.path))?[.size] as? Int64 ?? 0
|
||||
let mime = UTType(filenameExtension: destination.pathExtension)?.preferredMIMEType
|
||||
@@ -86,19 +99,42 @@ final class ShareViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
private static func uniqueURL(in directory: URL, name: String) -> URL {
|
||||
var candidate = directory.appendingPathComponent(name)
|
||||
var index = 1
|
||||
while FileManager.default.fileExists(atPath: candidate.path) {
|
||||
let ext = (name as NSString).pathExtension
|
||||
let stem = (name as NSString).deletingPathExtension
|
||||
candidate = directory.appendingPathComponent(ext.isEmpty ? "\(stem) (\(index))" : "\(stem) (\(index)).\(ext)")
|
||||
index += 1
|
||||
// MARK: - Actions
|
||||
|
||||
private func upload() {
|
||||
guard let account = model.account else { return }
|
||||
do {
|
||||
try ShareUpload.enqueue(items: model.items, account: account, remoteFolder: model.path)
|
||||
model.stage = .uploading
|
||||
complete(after: 1.2)
|
||||
} catch {
|
||||
model.stage = .failed("Couldn't start the upload: \(error.localizedDescription)")
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
private func notify(count: Int) {
|
||||
private func saveForLater() {
|
||||
guard let root = AppGroup.containerURL else { return cancel() }
|
||||
do {
|
||||
try SharedInbox.append(model.items, in: root)
|
||||
notifyOpenNoo(count: model.items.count)
|
||||
complete(after: 0)
|
||||
} catch {
|
||||
model.stage = .failed("Couldn't save to Noo: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
private func cancel() {
|
||||
if let batchDirectory { try? FileManager.default.removeItem(at: batchDirectory) }
|
||||
extensionContext?.cancelRequest(withError: CocoaError(.userCancelled))
|
||||
}
|
||||
|
||||
private func complete(after delay: TimeInterval) {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
|
||||
self.extensionContext?.completeRequest(returningItems: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func notifyOpenNoo(count: Int) {
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = "Noo"
|
||||
content.body =
|
||||
@@ -109,14 +145,4 @@ final class ShareViewController: UIViewController {
|
||||
UNUserNotificationCenter.current().add(
|
||||
UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil))
|
||||
}
|
||||
|
||||
private func finish(message: String, saved: Int) {
|
||||
DispatchQueue.main.async {
|
||||
self.spinner.stopAnimating()
|
||||
self.label.text = message
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + (saved > 0 ? 1.2 : 2.5)) {
|
||||
self.extensionContext?.completeRequest(returningItems: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user