mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 18:25:19 +00:00
Simplify PR: Focus on audio+image, fix EXIF stripping, remove file transfers
- Fix critical EXIF privacy issue in iOS image processing - Both iOS and macOS now use CGImageDestination for metadata stripping - Shared encodeJPEG function ensures no GPS, camera, or metadata leaks - Remove file transfer functionality to simplify PR scope - Deleted FileAttachmentView - Removed sendFileAttachment from ChatViewModel - Removed file picker UI from ContentView - Simplified attachment dialog to image only (iOS) or voice only - Keep focused media features: - Voice recording and playback - Image sending with progressive reveal - Binary protocol for media transfer
This commit is contained in:
@@ -1,10 +1,10 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import ImageIO
|
||||||
|
import UniformTypeIdentifiers
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
import UIKit
|
import UIKit
|
||||||
#else
|
#else
|
||||||
import AppKit
|
import AppKit
|
||||||
import ImageIO
|
|
||||||
import UniformTypeIdentifiers
|
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
enum ImageUtilsError: Error {
|
enum ImageUtilsError: Error {
|
||||||
@@ -40,19 +40,30 @@ enum ImageUtils {
|
|||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
static func processImage(_ image: UIImage, maxDimension: CGFloat = 512) throws -> URL {
|
static func processImage(_ image: UIImage, maxDimension: CGFloat = 512) throws -> URL {
|
||||||
return try autoreleasepool {
|
return try autoreleasepool {
|
||||||
|
// Scale the image first
|
||||||
let scaled = scaledImage(image, maxDimension: maxDimension)
|
let scaled = scaledImage(image, maxDimension: maxDimension)
|
||||||
var quality = compressionQuality
|
|
||||||
guard var jpegData = scaled.jpegData(compressionQuality: quality) else {
|
// Get CGImage from UIImage - this is the key to stripping metadata
|
||||||
|
guard let cgImage = scaled.cgImage else {
|
||||||
throw ImageUtilsError.encodingFailed
|
throw ImageUtilsError.encodingFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use CGImageDestination to encode without metadata (same as macOS)
|
||||||
|
var quality = compressionQuality
|
||||||
|
guard var jpegData = encodeJPEG(from: cgImage, quality: quality) else {
|
||||||
|
throw ImageUtilsError.encodingFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compress to target size
|
||||||
while jpegData.count > targetImageBytes && quality > 0.3 {
|
while jpegData.count > targetImageBytes && quality > 0.3 {
|
||||||
quality -= 0.1
|
quality -= 0.1
|
||||||
autoreleasepool {
|
autoreleasepool {
|
||||||
if let next = scaled.jpegData(compressionQuality: quality) {
|
if let next = encodeJPEG(from: cgImage, quality: quality) {
|
||||||
jpegData = next
|
jpegData = next
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let outputURL = try makeOutputURL()
|
let outputURL = try makeOutputURL()
|
||||||
try jpegData.write(to: outputURL, options: .atomic)
|
try jpegData.write(to: outputURL, options: .atomic)
|
||||||
return outputURL
|
return outputURL
|
||||||
@@ -65,12 +76,35 @@ enum ImageUtils {
|
|||||||
guard maxSide > maxDimension else { return image }
|
guard maxSide > maxDimension else { return image }
|
||||||
let scale = maxDimension / maxSide
|
let scale = maxDimension / maxSide
|
||||||
let newSize = CGSize(width: size.width * scale, height: size.height * scale)
|
let newSize = CGSize(width: size.width * scale, height: size.height * scale)
|
||||||
|
|
||||||
|
// Draw into a new context to get a clean CGImage without metadata
|
||||||
UIGraphicsBeginImageContextWithOptions(newSize, true, 1.0)
|
UIGraphicsBeginImageContextWithOptions(newSize, true, 1.0)
|
||||||
image.draw(in: CGRect(origin: .zero, size: newSize))
|
image.draw(in: CGRect(origin: .zero, size: newSize))
|
||||||
let rendered = UIGraphicsGetImageFromCurrentImageContext()
|
let rendered = UIGraphicsGetImageFromCurrentImageContext()
|
||||||
UIGraphicsEndImageContext()
|
UIGraphicsEndImageContext()
|
||||||
return rendered ?? image
|
return rendered ?? image
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shared EXIF-stripping JPEG encoder for both iOS and macOS
|
||||||
|
private static func encodeJPEG(from cgImage: CGImage, quality: CGFloat) -> Data? {
|
||||||
|
guard let data = CFDataCreateMutable(nil, 0) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
guard let destination = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Security: Strip ALL metadata (EXIF, GPS, TIFF, IPTC, XMP)
|
||||||
|
// By only specifying compression quality and no metadata keys,
|
||||||
|
// we ensure a clean JPEG with no privacy-leaking information
|
||||||
|
let options: [CFString: Any] = [
|
||||||
|
kCGImageDestinationLossyCompressionQuality: quality
|
||||||
|
]
|
||||||
|
CGImageDestinationAddImage(destination, cgImage, options as CFDictionary)
|
||||||
|
guard CGImageDestinationFinalize(destination) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return data as Data
|
||||||
|
}
|
||||||
#else
|
#else
|
||||||
static func processImage(_ image: NSImage, maxDimension: CGFloat = 512) throws -> URL {
|
static func processImage(_ image: NSImage, maxDimension: CGFloat = 512) throws -> URL {
|
||||||
return try autoreleasepool {
|
return try autoreleasepool {
|
||||||
@@ -130,6 +164,7 @@ enum ImageUtils {
|
|||||||
return scaledImage
|
return scaledImage
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shared EXIF-stripping JPEG encoder for both iOS and macOS
|
||||||
private static func encodeJPEG(from cgImage: CGImage, quality: CGFloat) -> Data? {
|
private static func encodeJPEG(from cgImage: CGImage, quality: CGFloat) -> Data? {
|
||||||
guard let data = CFDataCreateMutable(nil, 0) else {
|
guard let data = CFDataCreateMutable(nil, 0) else {
|
||||||
return nil
|
return nil
|
||||||
@@ -137,8 +172,9 @@ enum ImageUtils {
|
|||||||
guard let destination = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil) else {
|
guard let destination = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// Security H2: Strip ALL metadata (EXIF, GPS, TIFF, IPTC, XMP)
|
// Security: Strip ALL metadata (EXIF, GPS, TIFF, IPTC, XMP)
|
||||||
// Don't add any metadata dictionary keys - fresh CGContext ensures clean image
|
// By only specifying compression quality and no metadata keys,
|
||||||
|
// we ensure a clean JPEG with no privacy-leaking information
|
||||||
let options: [CFString: Any] = [
|
let options: [CFString: Any] = [
|
||||||
kCGImageDestinationLossyCompressionQuality: quality
|
kCGImageDestinationLossyCompressionQuality: quality
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -2503,65 +2503,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func sendFileAttachment(from sourceURL: URL) {
|
|
||||||
let targetPeer = selectedPrivateChatPeer
|
|
||||||
|
|
||||||
Task.detached(priority: .userInitiated) { [weak self] in
|
|
||||||
guard let self = self else { return }
|
|
||||||
defer { try? FileManager.default.removeItem(at: sourceURL) }
|
|
||||||
var destinationURL: URL?
|
|
||||||
do {
|
|
||||||
// Security H1: Check file size BEFORE reading into memory
|
|
||||||
let attrs = try FileManager.default.attributesOfItem(atPath: sourceURL.path)
|
|
||||||
guard let fileSize = attrs[.size] as? Int else {
|
|
||||||
throw MediaSendError.encodingFailed
|
|
||||||
}
|
|
||||||
guard FileTransferLimits.isValidPayload(fileSize) else {
|
|
||||||
throw MediaSendError.tooLarge
|
|
||||||
}
|
|
||||||
|
|
||||||
let data = try Data(contentsOf: sourceURL)
|
|
||||||
|
|
||||||
let destination = try self.prepareOutgoingFileCopy(from: sourceURL, data: data)
|
|
||||||
destinationURL = destination
|
|
||||||
let packet = BitchatFilePacket(
|
|
||||||
fileName: destination.lastPathComponent,
|
|
||||||
fileSize: UInt64(data.count),
|
|
||||||
mimeType: self.mimeType(for: destination),
|
|
||||||
content: data
|
|
||||||
)
|
|
||||||
guard packet.encode() != nil else {
|
|
||||||
try? FileManager.default.removeItem(at: destination)
|
|
||||||
throw MediaSendError.encodingFailed
|
|
||||||
}
|
|
||||||
|
|
||||||
await MainActor.run {
|
|
||||||
let message = self.enqueueMediaMessage(content: "[file] \(destination.lastPathComponent)", targetPeer: targetPeer?.id)
|
|
||||||
let messageID = message.id
|
|
||||||
let transferId = self.makeTransferID(messageID: messageID)
|
|
||||||
self.registerTransfer(transferId: transferId, messageID: messageID)
|
|
||||||
if let peerID = targetPeer {
|
|
||||||
self.meshService.sendFilePrivate(packet, to: peerID, transferId: transferId)
|
|
||||||
} else {
|
|
||||||
self.meshService.sendFileBroadcast(packet, transferId: transferId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch MediaSendError.tooLarge {
|
|
||||||
await MainActor.run {
|
|
||||||
self.addSystemMessage("File is too large to send.")
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
if let destination = destinationURL {
|
|
||||||
try? FileManager.default.removeItem(at: destination)
|
|
||||||
}
|
|
||||||
SecureLogger.error("File attachment send failed: \(error)", category: .session)
|
|
||||||
await MainActor.run {
|
|
||||||
self.addSystemMessage("Failed to send file.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func cancelMediaSend(messageID: String) {
|
func cancelMediaSend(messageID: String) {
|
||||||
@@ -2748,55 +2689,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func prepareOutgoingFileCopy(from sourceURL: URL, data: Data) throws -> URL {
|
|
||||||
let base = try applicationFilesDirectory().appendingPathComponent("files/outgoing", isDirectory: true)
|
|
||||||
try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true, attributes: nil)
|
|
||||||
|
|
||||||
let rawName = sourceURL.lastPathComponent
|
|
||||||
let sanitized = sanitizeOutgoingFileName(rawName.isEmpty ? "file" : rawName)
|
|
||||||
var destination = base.appendingPathComponent(sanitized)
|
|
||||||
var counter = 1
|
|
||||||
while FileManager.default.fileExists(atPath: destination.path) {
|
|
||||||
let baseName = (sanitized as NSString).deletingPathExtension
|
|
||||||
let ext = (sanitized as NSString).pathExtension
|
|
||||||
let newName: String
|
|
||||||
if ext.isEmpty {
|
|
||||||
newName = "\(baseName) (\(counter))"
|
|
||||||
} else {
|
|
||||||
newName = "\(baseName) (\(counter)).\(ext)"
|
|
||||||
}
|
|
||||||
destination = base.appendingPathComponent(newName)
|
|
||||||
counter += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
try data.write(to: destination, options: .atomic)
|
|
||||||
return destination
|
|
||||||
}
|
|
||||||
|
|
||||||
private func sanitizeOutgoingFileName(_ name: String) -> String {
|
|
||||||
var candidate = name
|
|
||||||
candidate = candidate.replacingOccurrences(of: "\\", with: "/")
|
|
||||||
candidate = candidate.components(separatedBy: "/").last ?? name
|
|
||||||
candidate = candidate.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
if candidate.isEmpty { candidate = "file" }
|
|
||||||
let invalid = CharacterSet(charactersIn: "<>:\"|?*")
|
|
||||||
candidate = candidate.components(separatedBy: invalid).joined(separator: "_")
|
|
||||||
if candidate.isEmpty { candidate = "file" }
|
|
||||||
if (candidate as NSString).pathExtension.isEmpty {
|
|
||||||
candidate += ".bin"
|
|
||||||
}
|
|
||||||
return candidate
|
|
||||||
}
|
|
||||||
|
|
||||||
private func mimeType(for url: URL) -> String {
|
|
||||||
let ext = url.pathExtension.lowercased()
|
|
||||||
if !ext.isEmpty,
|
|
||||||
let type = UTType(filenameExtension: ext),
|
|
||||||
let mime = type.preferredMIMEType {
|
|
||||||
return mime
|
|
||||||
}
|
|
||||||
return "application/octet-stream"
|
|
||||||
}
|
|
||||||
|
|
||||||
private func applicationFilesDirectory() throws -> URL {
|
private func applicationFilesDirectory() throws -> URL {
|
||||||
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||||
|
|||||||
@@ -71,7 +71,6 @@ struct ContentView: View {
|
|||||||
@State private var recordingDuration: TimeInterval = 0
|
@State private var recordingDuration: TimeInterval = 0
|
||||||
@State private var recordingTimer: Timer?
|
@State private var recordingTimer: Timer?
|
||||||
@State private var recordingStartDate: Date?
|
@State private var recordingStartDate: Date?
|
||||||
@State private var showFileImporter = false
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@State private var showPhotoPicker = false
|
@State private var showPhotoPicker = false
|
||||||
@State private var selectedPhotoPickerItem: PhotosPickerItem?
|
@State private var selectedPhotoPickerItem: PhotosPickerItem?
|
||||||
@@ -216,9 +215,6 @@ struct ContentView: View {
|
|||||||
Task { await handlePhotoSelection(item) }
|
Task { await handlePhotoSelection(item) }
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
.fileImporter(isPresented: $showFileImporter, allowedContentTypes: [.data], allowsMultipleSelection: false) { result in
|
|
||||||
handleImportResult(result, handler: handleImportedFile)
|
|
||||||
}
|
|
||||||
.sheet(isPresented: Binding(
|
.sheet(isPresented: Binding(
|
||||||
get: { imagePreviewURL != nil },
|
get: { imagePreviewURL != nil },
|
||||||
set: { presenting in if !presenting { imagePreviewURL = nil } }
|
set: { presenting in if !presenting { imagePreviewURL = nil } }
|
||||||
@@ -234,10 +230,6 @@ struct ContentView: View {
|
|||||||
DispatchQueue.main.async { showPhotoPicker = true }
|
DispatchQueue.main.async { showPhotoPicker = true }
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
Button("File") {
|
|
||||||
showAttachmentActions = false
|
|
||||||
DispatchQueue.main.async { showFileImporter = true }
|
|
||||||
}
|
|
||||||
Button("Cancel", role: .cancel) {}
|
Button("Cancel", role: .cancel) {}
|
||||||
}
|
}
|
||||||
.alert("Recording Error", isPresented: $showRecordingAlert, actions: {
|
.alert("Recording Error", isPresented: $showRecordingAlert, actions: {
|
||||||
@@ -1547,11 +1539,10 @@ struct ContentView: View {
|
|||||||
private enum MessageMedia {
|
private enum MessageMedia {
|
||||||
case voice(URL)
|
case voice(URL)
|
||||||
case image(URL)
|
case image(URL)
|
||||||
case file(URL)
|
|
||||||
|
|
||||||
var url: URL {
|
var url: URL {
|
||||||
switch self {
|
switch self {
|
||||||
case .voice(let url), .image(let url), .file(let url):
|
case .voice(let url), .image(let url):
|
||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1589,13 +1580,6 @@ private extension ContentView {
|
|||||||
let url = baseDirectory.appendingPathComponent(subdir, isDirectory: true).appendingPathComponent(filename)
|
let url = baseDirectory.appendingPathComponent(subdir, isDirectory: true).appendingPathComponent(filename)
|
||||||
return .image(url)
|
return .image(url)
|
||||||
}
|
}
|
||||||
if message.content.hasPrefix("[file] ") {
|
|
||||||
let filename = String(message.content.dropFirst("[file] ".count)).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
guard !filename.isEmpty else { return nil }
|
|
||||||
let subdir = message.sender == viewModel.nickname ? "files/outgoing" : "files/incoming"
|
|
||||||
let url = baseDirectory.appendingPathComponent(subdir, isDirectory: true).appendingPathComponent(filename)
|
|
||||||
return .file(url)
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1687,13 +1671,6 @@ private extension ContentView {
|
|||||||
} : nil
|
} : nil
|
||||||
)
|
)
|
||||||
.frame(maxWidth: 280)
|
.frame(maxWidth: 280)
|
||||||
case .file(let url):
|
|
||||||
FileAttachmentView(
|
|
||||||
url: url,
|
|
||||||
isSending: state.isSending,
|
|
||||||
progress: state.progress,
|
|
||||||
onCancel: cancelAction
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2024,24 +2001,6 @@ private extension ContentView {
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
func handleImportedFile(url: URL) async {
|
|
||||||
do {
|
|
||||||
let fileManager = FileManager.default
|
|
||||||
let tempDir = fileManager.temporaryDirectory
|
|
||||||
let fileName = url.lastPathComponent.isEmpty ? "attachment" : url.lastPathComponent
|
|
||||||
let destination = tempDir.appendingPathComponent(UUID().uuidString + "_" + fileName)
|
|
||||||
if fileManager.fileExists(atPath: destination.path) {
|
|
||||||
try fileManager.removeItem(at: destination)
|
|
||||||
}
|
|
||||||
try fileManager.copyItem(at: url, to: destination)
|
|
||||||
await MainActor.run {
|
|
||||||
viewModel.sendFileAttachment(from: destination)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
SecureLogger.error("File copy failed before send: \(error)", category: .session)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func applicationFilesDirectory() -> URL? {
|
func applicationFilesDirectory() -> URL? {
|
||||||
// Cache the directory lookup to avoid repeated FileManager calls during view rendering
|
// Cache the directory lookup to avoid repeated FileManager calls during view rendering
|
||||||
struct Cache {
|
struct Cache {
|
||||||
|
|||||||
@@ -1,118 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct FileAttachmentView: View {
|
|
||||||
private let url: URL
|
|
||||||
private let isSending: Bool
|
|
||||||
private let progress: Double?
|
|
||||||
private let onCancel: (() -> Void)?
|
|
||||||
|
|
||||||
@Environment(\.colorScheme) private var colorScheme
|
|
||||||
#if os(iOS)
|
|
||||||
@State private var showExporter = false
|
|
||||||
#endif
|
|
||||||
|
|
||||||
init(url: URL, isSending: Bool, progress: Double?, onCancel: (() -> Void)?) {
|
|
||||||
self.url = url
|
|
||||||
self.isSending = isSending
|
|
||||||
self.progress = progress
|
|
||||||
self.onCancel = onCancel
|
|
||||||
}
|
|
||||||
|
|
||||||
private var fileName: String {
|
|
||||||
url.lastPathComponent
|
|
||||||
}
|
|
||||||
|
|
||||||
private var normalizedProgress: Double? {
|
|
||||||
guard let progress = progress else { return nil }
|
|
||||||
return max(0, min(1, progress))
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack(alignment: .center, spacing: 12) {
|
|
||||||
Image(systemName: "doc.fill")
|
|
||||||
.foregroundColor(Color.blue)
|
|
||||||
.font(.bitchatSystem(size: 24))
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
Text(fileName)
|
|
||||||
.font(.bitchatSystem(size: 14, weight: .medium))
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
.lineLimit(2)
|
|
||||||
Text(url.lastPathComponent)
|
|
||||||
.font(.bitchatSystem(size: 11, design: .monospaced))
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.lineLimit(1)
|
|
||||||
if let progress = normalizedProgress {
|
|
||||||
ProgressView(value: progress)
|
|
||||||
.progressViewStyle(.linear)
|
|
||||||
.tint(Color.blue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Button(action: openFile) {
|
|
||||||
Text("open", comment: "Button to open attached file")
|
|
||||||
.font(.bitchatSystem(size: 13, weight: .semibold))
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.padding(.vertical, 6)
|
|
||||||
.background(
|
|
||||||
Capsule().fill(Color.blue.opacity(0.15))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
|
|
||||||
if let onCancel = onCancel, isSending {
|
|
||||||
Button(action: onCancel) {
|
|
||||||
Image(systemName: "xmark")
|
|
||||||
.font(.bitchatSystem(size: 11, weight: .bold))
|
|
||||||
.frame(width: 26, height: 26)
|
|
||||||
.background(Circle().fill(Color.red.opacity(0.9)))
|
|
||||||
.foregroundColor(.white)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(12)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 14)
|
|
||||||
.fill(colorScheme == .dark ? Color.black.opacity(0.6) : Color.white)
|
|
||||||
)
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 14)
|
|
||||||
.stroke(Color.gray.opacity(0.2), lineWidth: 1)
|
|
||||||
)
|
|
||||||
#if os(iOS)
|
|
||||||
.sheet(isPresented: $showExporter) {
|
|
||||||
FileExportController(url: url)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
private func openFile() {
|
|
||||||
#if os(iOS)
|
|
||||||
showExporter = true
|
|
||||||
#else
|
|
||||||
NSWorkspace.shared.open(url)
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
import UniformTypeIdentifiers
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
private struct FileExportController: UIViewControllerRepresentable {
|
|
||||||
let url: URL
|
|
||||||
|
|
||||||
func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
|
|
||||||
let controller = UIDocumentPickerViewController(forExporting: [url])
|
|
||||||
controller.shouldShowFileExtensions = true
|
|
||||||
return controller
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {}
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
import AppKit
|
|
||||||
#endif
|
|
||||||
Reference in New Issue
Block a user