Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/mobile/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,7 @@ const config: ExpoConfig = {
},
NSLocalNetworkUsageDescription:
"Allow Pylon to connect to Pylon servers on your local network or tailnet.",
NSPhotoLibraryAddUsageDescription: "Allow Pylon to save images to your photo library.",
ITSAppUsesNonExemptEncryption: false,
// The App Store screenshot harness rotates the iPad interface from
// inside the app (CI denies osascript the Accessibility access that
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,57 @@ import Security
import UIKit

public final class T3NativeControlsModule: Module {
private let presentationSources = T3PresentationSources()
private var videoPresentation: T3NativeVideoPresentation?
private var filePresentation: T3NativeFilePresentation?

public func definition() -> ModuleDefinition {
Name("T3NativeControls")

AsyncFunction("presentVideo") { (url: URL, title: String, sourceIdentifier: String, identifier: String, promise: Promise) in
try self.presentVideo(
url: url,
title: title,
sourceIdentifier: sourceIdentifier,
identifier: identifier,
promise: promise
)
}.runOnQueue(.main)

AsyncFunction("dismissVideo") { (identifier: String) in
self.dismissVideo(identifier: identifier)
}.runOnQueue(.main)

AsyncFunction("presentFile") { (url: URL, title: String, sourceIdentifier: String, identifier: String, promise: Promise) in
try self.presentFile(url: url, title: title, sourceIdentifier: sourceIdentifier,
identifier: identifier, promise: promise)
}.runOnQueue(.main)

AsyncFunction("dismissFile") { (identifier: String) in
self.dismissFile(identifier: identifier)
}.runOnQueue(.main)

OnDestroy {
let presentation = self.videoPresentation
let file = self.filePresentation
DispatchQueue.main.async {
presentation?.dismiss()
file?.dismiss()
}
}

View(T3PresentationSourceView.self) {
ViewName("PresentationSource")
Prop("identifier") { (view: T3PresentationSourceView, identifier: String) in
view.sources = self.presentationSources
view.identifier = identifier
}
}

AsyncFunction("shareFileFromSource") { (url: URL, title: String, identifier: String, promise: Promise) in
try self.shareFile(url: url, title: title, sourceIdentifier: identifier, promise: promise)
}.runOnQueue(.main)

Function("getShowcasePairingUrl") {
let arguments = ProcessInfo.processInfo.arguments
guard
Expand Down Expand Up @@ -101,4 +149,65 @@ public final class T3NativeControlsModule: Module {
try? scene.write(toFile: readyPath, atomically: true, encoding: .utf8)
}
}

private func presentVideo(url: URL, title: String, sourceIdentifier: String, identifier: String, promise: Promise) throws {
let isPlayableURL = url.isFileURL
? FileManager.default.isReadableFile(atPath: url.path)
: (["https", "http"].contains(url.scheme?.lowercased() ?? "") && url.host != nil)
guard videoPresentation == nil, filePresentation == nil,
let presenter = appContext?.utilities?.currentViewController(),
isPlayableURL
else {
throw NSError(
domain: "T3NativeVideo",
code: 2,
userInfo: [NSLocalizedDescriptionKey: "The video preview is no longer available."]
)
}
let presentation = T3NativeVideoPresentation(identifier: identifier, url: url, title: title) { [weak self] error in
self?.videoPresentation = nil
if let error { promise.reject(error) } else { promise.resolve(nil) }
}
videoPresentation = presentation
presentation.present(from: presenter, sources: presentationSources, sourceIdentifier: sourceIdentifier)
}

private func dismissVideo(identifier: String) {
if videoPresentation?.identifier == identifier { videoPresentation?.dismiss() }
}

private func presentFile(url: URL, title: String, sourceIdentifier: String,
identifier: String, promise: Promise) throws {
guard filePresentation == nil, videoPresentation == nil,
let presenter = appContext?.utilities?.currentViewController()
else { throw URLError(.cannotLoadFromNetwork) }
let file = T3NativeFilePresentation(identifier: identifier, sources: presentationSources,
sourceIdentifier: sourceIdentifier) { [weak self] error in
self?.filePresentation = nil
if let error { promise.reject(error) } else { promise.resolve(nil) }
}
filePresentation = file
file.present(url: url, title: title, from: presenter)
}

private func dismissFile(identifier: String) {
if filePresentation?.identifier == identifier { filePresentation?.dismiss() }
}

private func shareFile(url: URL, title: String, sourceIdentifier: String, promise: Promise) throws {
guard let presenter = appContext?.utilities?.currentViewController() else {
throw NSError(
domain: "T3NativePresentation",
code: 2,
userInfo: [NSLocalizedDescriptionKey: "The presenting screen is no longer open."]
)
}
try presentFileShare(
url: url,
title: title,
source: presentationSources.view(for: sourceIdentifier),
presenter: presenter,
promise: promise
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import ImageIO
import QuickLook
import UIKit
import UniformTypeIdentifiers

private final class FilePreviewItem: NSObject, QLPreviewItem {
var previewItemURL: URL?
var previewItemTitle: String?
}

private final class FilePreviewController: QLPreviewController {
var onAppear: (() -> Void)?

override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
onAppear?()
}
}

/// Quick Look owns image and document controls, zooming, and source-view transitions.
final class T3NativeFilePresentation: NSObject, QLPreviewControllerDataSource,
QLPreviewControllerDelegate, UIAdaptivePresentationControllerDelegate {
let identifier: String
private var controller: UIViewController?
private let completion: (Error?) -> Void
private weak var sources: T3PresentationSources?
private let sourceIdentifier: String
private let item = FilePreviewItem()
private var loading: Task<Void, Never>?
private var dismissRequested = false
private var finished = false

init(identifier: String, sources: T3PresentationSources, sourceIdentifier: String, completion: @escaping (Error?) -> Void) {
self.identifier = identifier
self.sources = sources
self.sourceIdentifier = sourceIdentifier
self.completion = completion
super.init()
}

func present(url: URL, title: String, from presenter: UIViewController) {
loading = Task { @MainActor [self] in
do {
let file = try await Self.prepareFile(url: url, title: title)
guard !finished, !Task.isCancelled else {
try? FileManager.default.removeItem(at: file.deletingLastPathComponent())
return
}
item.previewItemURL = file
item.previewItemTitle = title
let preview = FilePreviewController()
preview.delegate = self
preview.dataSource = self
preview.onAppear = { [weak self] in self?.resumePendingDismissal() }
controller = preview
presenter.present(preview, animated: !UIAccessibility.isReduceMotionEnabled) { [self] in
resumePendingDismissal()
}
preview.presentationController?.delegate = self
} catch {
finish(error: error)
}
}
}

func dismiss() {
dismissRequested = true
loading?.cancel()
guard !finished else { return }
guard let controller else { finish(); return }
// Drain Close from viewDidAppear after opening or cancelling an interactive dismissal.
// Starting a second modal transition while UIKit is settling the first can strand it.
guard !controller.isBeingPresented, !controller.isBeingDismissed else { return }
controller.dismiss(animated: !UIAccessibility.isReduceMotionEnabled) { [self] in finish() }
}

private func resumePendingDismissal() {
// Appearance callbacks run before UIKit has cleared the current transition.
DispatchQueue.main.async { [weak self] in
if self?.dismissRequested == true { self?.dismiss() }
}
}

func numberOfPreviewItems(in controller: QLPreviewController) -> Int { item.previewItemURL == nil ? 0 : 1 }

func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem {
item
}

func previewController(_ controller: QLPreviewController, transitionViewFor item: QLPreviewItem) -> UIView? {
guard !UIAccessibility.isReduceMotionEnabled else { return nil }
return sources?.view(for: sourceIdentifier)
}

func previewController(_ controller: QLPreviewController, frameFor item: QLPreviewItem,
inSourceView view: AutoreleasingUnsafeMutablePointer<UIView?>) -> CGRect {
guard !UIAccessibility.isReduceMotionEnabled, let source = sources?.view(for: sourceIdentifier) else { return .zero }
view.pointee = source
return source.bounds
}

func previewControllerDidDismiss(_ controller: QLPreviewController) { finish() }

func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { finish() }

private func finish(error: Error? = nil) {
guard !finished else { return }
finished = true
loading?.cancel()
loading = nil
if let file = item.previewItemURL {
try? FileManager.default.removeItem(at: file.deletingLastPathComponent())
}
item.previewItemURL = nil
DispatchQueue.main.async { [completion] in completion(error) }
}

/// Copy original bytes so preview and sharing do not mutate a draft or workspace file.
nonisolated private static func prepareFile(url: URL, title: String) async throws -> URL {
try Task.checkCancellation()
let directory = FileManager.default.temporaryDirectory.appendingPathComponent("t3-preview-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
do {
let download = directory.appendingPathComponent("original")
if url.isFileURL {
try FileManager.default.copyItem(at: url, to: download)
} else if url.scheme == "data" {
try Data(contentsOf: url).write(to: download, options: .atomic)
} else {
guard ["https", "http"].contains(url.scheme?.lowercased() ?? "") else {
throw URLError(.unsupportedURL)
}
let (temporaryFile, response) = try await URLSession.shared.download(from: url)
guard let response = response as? HTTPURLResponse, (200..<300).contains(response.statusCode) else {
throw URLError(.badServerResponse)
}
try FileManager.default.moveItem(at: temporaryFile, to: download)
}
try Task.checkCancellation()
let type: UTType
if let image = CGImageSourceCreateWithURL(download as CFURL, nil),
CGImageSourceGetCount(image) > 0, let imageType = CGImageSourceGetType(image),
let detectedType = UTType(imageType as String) {
type = detectedType
} else if CGPDFDocument(download as CFURL) != nil {
type = .pdf
} else {
throw URLError(.cannotDecodeContentData)
}
let filename = URL(fileURLWithPath: title).lastPathComponent as NSString
let originalExtension = filename.pathExtension
let fileExtension = UTType(filenameExtension: originalExtension) == type
? originalExtension : type.preferredFilenameExtension ?? "png"
let stem = filename.deletingPathExtension
var name = String(stem.prefix(60)).components(separatedBy: .controlCharacters).joined(separator: "_")
while name.utf8.count > 200 { name.removeLast() }
let file = directory.appendingPathComponent("\(name.isEmpty ? "Preview" : name).\(fileExtension)")
try FileManager.default.moveItem(at: download, to: file)
return file
} catch {
try? FileManager.default.removeItem(at: directory)
throw error
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import ExpoModulesCore
import UIKit

final class T3PresentationSources {
private class Entry {
weak var view: UIView?
init(_ view: UIView) { self.view = view }
}

private var entries: [String: Entry] = [:]

func register(_ view: UIView, identifier: String) {
entries[identifier] = Entry(view)
}

func remove(_ view: UIView, identifier: String) {
if entries[identifier]?.view == nil || entries[identifier]?.view === view {
entries.removeValue(forKey: identifier)
}
}

func view(for identifier: String) -> UIView? {
// Use the child bounds, not the wrapper's potentially stretched layout bounds.
entries[identifier]?.view?.subviews.first
}
}

final class T3PresentationSourceView: ExpoView {
weak var sources: T3PresentationSources?
var identifier = "" {
didSet {
sources?.remove(self, identifier: oldValue)
if !identifier.isEmpty { sources?.register(self, identifier: identifier) }
}
}

deinit {
sources?.remove(self, identifier: identifier)
}
}

func presentFileShare(
url: URL,
title: String,
source: UIView?,
presenter: UIViewController,
promise: Promise
) throws {
guard url.isFileURL, FileManager.default.isReadableFile(atPath: url.path) else {
throw NSError(
domain: "T3NativePresentation",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "The file is no longer available."]
)
}

guard let origin = source ?? presenter.view else {
throw NSError(
domain: "T3NativePresentation",
code: 2,
userInfo: [NSLocalizedDescriptionKey: "The presenting screen is no longer open."]
)
}

let activity = UIActivityViewController(activityItems: [url], applicationActivities: nil)
activity.title = title
activity.overrideUserInterfaceStyle = source?.traitCollection.userInterfaceStyle
?? presenter.traitCollection.userInterfaceStyle
activity.completionWithItemsHandler = { _, _, _, _ in promise.resolve(nil) }
activity.modalPresentationStyle = .popover
activity.popoverPresentationController?.sourceView = origin
activity.popoverPresentationController?.sourceRect = source?.bounds
?? CGRect(x: origin.bounds.midX, y: origin.bounds.maxY, width: 0, height: 0)
presenter.present(activity, animated: true)
}
Loading
Loading