b55cb615a3
- MacNotifier:窗口非前台(!NSApp.isActive)时对「收到消息 / 收到文件」发原生系统通知(UNUserNotificationCenter,授权已由引擎启动的 PushRegistry.requestAuthorizationAndRegister 一并请求)。对齐桌面 app.ShowNotification——前台事件由应用内 NoticeOverlay / 未读红点负责,仅 app 非活跃才发系统通知,避免与应用内提示重复。引擎 handleNotify 的 "message"(新到对端消息,非当前会话)+ "transferDone"(incoming 且 completed)两处加 macOS 挂钩 - 接收目录可配:DownloadManager.downloadDir 在 macOS 读 UserDefaults macDownloadDir(设置页 NSOpenPanel 选目录),未配 / 目录已失效则回退系统「下载」目录(比「文稿」更符桌面直觉);iOS 仍固定沙盒 Documents。设置加「接收」区(显示当前保存目录 + 选择目录…)。非沙盒构建用普通路径即可;沙盒分发(Phase 6)须改安全作用域书签 - 验证:just mac-build(CommilitiaDropMac.app)+ iOS 模拟器(CommilitiaDrop.app)均 BUILD SUCCEEDED
135 lines
5.0 KiB
Swift
135 lines
5.0 KiB
Swift
import Foundation
|
||
|
||
// 接收落盘管理器(对应桌面 desktop/platform/download.go):把接收到的字节写入 app 沙盒
|
||
// Documents。小文件整文件写(saveWhole);大文件流式 begin / append / finalize;abort
|
||
// 丢弃临时文件。线程:由 EngineController 在主线程调用,文件 IO 同步执行(首版求简单可
|
||
// 验,后续可挪后台队列)。
|
||
final class DownloadManager
|
||
{
|
||
private struct Active
|
||
{
|
||
let handle: FileHandle
|
||
let tmpURL: URL
|
||
}
|
||
|
||
private var active: [String: Active] = [:]
|
||
|
||
private func downloadDir() -> URL
|
||
{
|
||
#if os(macOS)
|
||
// macOS:可配置接收目录(UserDefaults macDownloadDir,设置页选)。未配 / 已失效则回退到系统
|
||
// 「下载」目录(比「文稿」更符桌面直觉)。非沙盒构建用普通路径即可;沙盒分发(Phase 6)须改
|
||
// 安全作用域书签。
|
||
if let path = UserDefaults.standard.string(forKey: "macDownloadDir"), !path.isEmpty
|
||
{
|
||
let url = URL(fileURLWithPath: path, isDirectory: true)
|
||
if FileManager.default.fileExists(atPath: url.path) { return url }
|
||
}
|
||
return FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
|
||
?? FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||
#else
|
||
return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||
#endif
|
||
}
|
||
|
||
// 列出已落盘的接收文件(Documents 顶层,排除流式临时 .part / 隐藏文件),最近的在前。
|
||
// 供应用内「收到的文件」视图浏览 / 打开 / 转发,也是 Files app 可见的同一批文件。
|
||
func receivedFiles() -> [URL]
|
||
{
|
||
let urls = (try? FileManager.default.contentsOfDirectory(
|
||
at: downloadDir(),
|
||
includingPropertiesForKeys: [ .contentModificationDateKey ],
|
||
options: [ .skipsHiddenFiles ])) ?? []
|
||
return urls
|
||
.filter { $0.pathExtension != "part" && !$0.lastPathComponent.hasPrefix(".cdrop-") }
|
||
.sorted { a, b in modDate(a) > modDate(b) }
|
||
}
|
||
|
||
func deleteReceivedFile(_ url: URL)
|
||
{
|
||
try? FileManager.default.removeItem(at: url)
|
||
}
|
||
|
||
private func modDate(_ url: URL) -> Date
|
||
{
|
||
return (try? url.resourceValues(forKeys: [ .contentModificationDateKey ]))?
|
||
.contentModificationDate ?? .distantPast
|
||
}
|
||
|
||
// 整文件写入(接收端小文件路径,对应桥 saveDownload)。返回最终绝对路径。
|
||
func saveWhole(name: String, data: Data) throws -> String
|
||
{
|
||
let url = uniqueURL(for: sanitize(name))
|
||
try data.write(to: url)
|
||
return url.path
|
||
}
|
||
|
||
func begin(sessionId: String) throws
|
||
{
|
||
cleanup(sessionId) // re-begin 安全:先清旧条目再开新文件
|
||
let tmp = downloadDir().appendingPathComponent(".cdrop-\(sanitize(sessionId)).part")
|
||
FileManager.default.createFile(atPath: tmp.path, contents: nil)
|
||
let handle = try FileHandle(forWritingTo: tmp)
|
||
active[sessionId] = Active(handle: handle, tmpURL: tmp)
|
||
}
|
||
|
||
func append(sessionId: String, data: Data) throws
|
||
{
|
||
guard let a = active[sessionId] else { throw Err.unknownSession }
|
||
try a.handle.write(contentsOf: data)
|
||
}
|
||
|
||
func finalize(sessionId: String, name: String) throws -> String
|
||
{
|
||
guard let a = active[sessionId] else { throw Err.unknownSession }
|
||
try a.handle.close()
|
||
active[sessionId] = nil
|
||
let dest = uniqueURL(for: sanitize(name))
|
||
try FileManager.default.moveItem(at: a.tmpURL, to: dest)
|
||
return dest.path
|
||
}
|
||
|
||
func abort(sessionId: String)
|
||
{
|
||
cleanup(sessionId)
|
||
}
|
||
|
||
private func cleanup(_ sessionId: String)
|
||
{
|
||
guard let a = active[sessionId] else { return }
|
||
try? a.handle.close()
|
||
try? FileManager.default.removeItem(at: a.tmpURL)
|
||
active[sessionId] = nil
|
||
}
|
||
|
||
// 唯一化:若已存在则插入 (1) (2)…(对应桌面 uniquePath)。
|
||
private func uniqueURL(for name: String) -> URL
|
||
{
|
||
let dir = downloadDir()
|
||
let base = (name as NSString).deletingPathExtension
|
||
let ext = (name as NSString).pathExtension
|
||
var candidate = dir.appendingPathComponent(name)
|
||
var i = 1
|
||
while FileManager.default.fileExists(atPath: candidate.path)
|
||
{
|
||
let next = ext.isEmpty ? "\(base) (\(i))" : "\(base) (\(i)).\(ext)"
|
||
candidate = dir.appendingPathComponent(next)
|
||
i += 1
|
||
}
|
||
return candidate
|
||
}
|
||
|
||
// 文件名 sanitize:只取末段、剥路径分隔符,防目录穿越(对应桌面 sanitizeFileName)。
|
||
private func sanitize(_ name: String) -> String
|
||
{
|
||
let last = (name as NSString).lastPathComponent
|
||
let cleaned = last.replacingOccurrences(of: "/", with: "_")
|
||
return cleaned.isEmpty ? "download" : cleaned
|
||
}
|
||
|
||
enum Err: Error
|
||
{
|
||
case unknownSession
|
||
}
|
||
}
|