Files
Luma/Sources/Shared/Services/MediaDownloadService.swift
T
wt d4b4c92759
iOS CI / Build and Test SwiftUI App (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build Unsigned iOS IPA (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build macOS ZIP (push) Canceled after 0s
tls sasl
2026-09-02 11:46:11 +07:00

248 lines
8.5 KiB
Swift

import Foundation
import UniformTypeIdentifiers
#if os(iOS)
import Photos
import UIKit
#elseif os(macOS)
import AppKit
#endif
/// Saves a message's cached media to the right destination: photos, videos and
/// video notes go to the Photos library on iOS (or Downloads on macOS, which
/// would otherwise need a Photo Library entitlement), while files, audio and
/// voice messages go to the user's Downloads location. Kept dependency-free so
/// every bubble, the full-screen viewer and the audio player share one path.
@MainActor
enum MediaDownloadService {
enum SaveError: LocalizedError {
case missingLocalFile
case unsupportedKind
var errorDescription: String? {
switch self {
case .missingLocalFile:
return "Не удалось загрузить файл для сохранения."
case .unsupportedKind:
return "Этот тип сообщения нельзя сохранить."
}
}
}
// MARK: - Entry points
/// Resolve the cached copy for `message` (downloading through AppModel when
/// needed) and save it. Failures are surfaced through AppModel's error alert.
static func save(_ message: ChatMessage, model: AppModel) async {
do {
guard let sourceURL = await resolvedLocalURL(for: message, model: model) else {
throw SaveError.missingLocalFile
}
let filename = message.localFilename ?? Self.defaultFilename(for: message)
let didSave = try await save(
url: sourceURL,
kind: message.kind,
filename: filename
)
if didSave {
model.informationalMessage = "Сохранено."
}
} catch {
model.errorMessage = error.localizedDescription
}
}
/// Save an already-downloaded file, routing by kind and platform. Returns
/// `false` when the user cancelled an interactive save sheet.
@discardableResult
static func save(
url: URL,
kind: ChatMessage.Kind,
filename: String
) async throws -> Bool {
switch kind {
case .photo, .video, .videoNote:
#if os(iOS)
try await saveToPhotos(url: url, kind: kind)
return true
#else
return try await saveToDownloads(url: url, filename: filename)
#endif
case .attachment, .audio, .voice:
return try await saveToDownloads(url: url, filename: filename)
case .text, .location, .system:
throw SaveError.unsupportedKind
}
}
// MARK: - Local file resolution
private static func resolvedLocalURL(
for message: ChatMessage,
model: AppModel
) async -> URL? {
if let cached = model.mediaPreviewURL(for: message) {
return cached
}
// Inline media (photo/video/audio/voice/video note) is fetched by
// AppModel into its preview cache; files are not auto-previewed and fall
// through to the QuickLook handoff below.
await model.prepareMediaPreview(message)
if let cached = model.mediaPreviewURL(for: message) {
return cached
}
// Attachments are only fetched on demand via previewAttachment(_:),
// which parks the result in the single-shot previewURL property. Clear it
// before and after so the QuickLook sheet does not flash open.
model.previewURL = nil
await model.previewAttachment(message)
let url = model.previewURL
model.previewURL = nil
return url
}
// MARK: - Photos
#if os(iOS)
private static func saveToPhotos(url: URL, kind: ChatMessage.Kind) async throws {
try await PHPhotoLibrary.shared().performChanges {
if kind == .photo {
_ = PHAssetChangeRequest.creationRequestForAssetFromImage(atFileURL: url)
} else {
_ = PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: url)
}
}
}
#endif
// MARK: - Downloads
private static func saveToDownloads(url: URL, filename: String) async throws -> Bool {
#if os(macOS)
return try await saveWithPanel(url: url, filename: filename)
#else
return await FileExportPresenter.shared.present(url: url)
#endif
}
#if os(macOS)
/// Present a save panel defaulting to ~/Downloads. The app sandbox grants
/// write access to the location the user picks, so no Downloads entitlement
/// is required.
private static func saveWithPanel(url: URL, filename: String) async throws -> Bool {
let panel = NSSavePanel()
panel.nameFieldStringValue = filename
panel.canCreateDirectories = true
if let downloads = FileManager.default.urls(
for: .downloadsDirectory,
in: .userDomainMask
).first {
panel.directoryURL = downloads
}
guard panel.runModal() == .OK, let destination = panel.url else {
return false
}
try FileManager.default.copyItem(at: url, to: destination)
return true
}
#endif
// MARK: - Helpers
private static func defaultFilename(for message: ChatMessage) -> String {
let ext = Self.fileExtension(for: message)
let base: String
switch message.kind {
case .photo: base = "Фото"
case .video: base = "Видео"
case .videoNote: base = "Видеосообщение"
case .voice: base = "Голосовое сообщение"
case .audio: base = "Аудио"
case .attachment: base = "Вложение"
default: base = "Файл"
}
return ext.isEmpty ? base : "\(base).\(ext)"
}
private static func fileExtension(for message: ChatMessage) -> String {
if let filename = message.localFilename {
let ext = (filename as NSString).pathExtension
if !ext.isEmpty { return ext }
}
if let mime = message.mimeType,
let type = UTType(mimeType: mime),
let ext = type.preferredFilenameExtension
{
return ext
}
switch message.kind {
case .video, .videoNote: return "mp4"
case .voice, .audio: return "m4a"
case .photo: return "jpg"
default: return ""
}
}
}
#if os(iOS)
/// Presents the system "save to Files" sheet for a cached file and reports
/// whether the user completed the save or cancelled. Held alive by the
/// continuation until the delegate fires.
@MainActor
private final class FileExportPresenter: NSObject, UIDocumentPickerDelegate {
static let shared = FileExportPresenter()
private var continuation: CheckedContinuation<Bool, Never>?
func present(url: URL) async -> Bool {
await withCheckedContinuation { continuation in
self.continuation = continuation
let picker = UIDocumentPickerViewController(forExporting: [url], asCopy: true)
picker.delegate = self
Task { @MainActor in
// Let a just-dismissed context menu finish its transition before
// presenting, otherwise the sheet can be rejected.
try? await Task.sleep(nanoseconds: 350_000_000)
guard let presenter = Self.topPresenter(),
presenter.presentedViewController == nil
else {
self.finish(false)
return
}
presenter.present(picker, animated: true)
}
}
}
func documentPicker(
_ controller: UIDocumentPickerViewController,
didPickDocumentsAt urls: [URL]
) {
finish(true)
}
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
finish(false)
}
private func finish(_ result: Bool) {
continuation?.resume(returning: result)
continuation = nil
}
private static func topPresenter() -> UIViewController? {
guard let scene = UIApplication.shared.connectedScenes
.compactMap({ $0 as? UIWindowScene })
.first(where: { $0.activationState == .foregroundActive }),
let window = scene.windows.first(where: { $0.isKeyWindow }),
let root = window.rootViewController
else { return nil }
var top = root
while let presented = top.presentedViewController {
top = presented
}
return top
}
}
#endif