ce55be4a-fb5f-4981-b507-0f4.../Sources/AirDropState.swift
徐翔宇 c4080b5cb9 v1.0.0: initial AirDrop plugin for MioIsland
Quick-access AirDrop from the notch panel. Tap the card body, pick
files via NSOpenPanel, hand off to NSSharingService(.sendViaAirDrop).
No private APIs, no entitlements, no network — just a thin wrapper
around the same AirDrop API Finder uses.

Design constraint: drag-and-drop is intentionally not implemented.
MioIsland's notch panel auto-collapses on click-outside, which
breaks the Cmd-Tab-to-Finder / grab / drag-back workflow before
the drop target reaches. Tap-to-choose is rock solid by comparison.

Requires MioIsland host v2.2.0+ (panel size clamp floor was
lowered to 120pt in that release).
2026-04-22 08:20:47 +08:00

84 lines
2.8 KiB
Swift

//
// AirDropState.swift
// MioIsland AirDrop Plugin
//
// Shared state + send coordinator. Single source of truth for what
// the panel should render. Not strictly "ObservableObject" heavy
// we have only three phases, but using @Published keeps the door
// open for future "sending progress" UI.
//
import AppKit
import Combine
import Foundation
@MainActor
final class AirDropState: ObservableObject {
static let shared = AirDropState()
enum Phase: Equatable {
case idle
case showingPicker
case sent(count: Int)
case error(message: String)
}
@Published var phase: Phase = .idle
/// Last completed send used to animate a brief " " toast.
@Published private(set) var lastSendAt: Date?
/// Called by UI when user drops files or picks from NSOpenPanel.
/// Kicks off NSSharingService (opens the AirDrop chooser window)
/// and resets phase when the user dismisses the chooser.
func send(files: [URL]) {
let urls = files.filter { $0.isFileURL }
guard !urls.isEmpty else { return }
phase = .showingPicker
AirDropService.perform(files: urls) { [weak self] result in
guard let self else { return }
Task { @MainActor in
switch result {
case .success:
self.phase = .sent(count: urls.count)
self.lastSendAt = Date()
// Drop back to idle after 2.5s so the drop zone is
// usable for the next file without a click.
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) { [weak self] in
guard let self else { return }
if case .sent = self.phase {
self.phase = .idle
}
}
case .failure(let err):
self.phase = .error(message: err.localizedDescription)
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
guard let self else { return }
if case .error = self.phase {
self.phase = .idle
}
}
}
}
}
}
/// Open NSOpenPanel to let the user pick files manually, then send.
func chooseAndSend() {
let panel = NSOpenPanel()
panel.allowsMultipleSelection = true
panel.canChooseDirectories = true
panel.canChooseFiles = true
panel.message = L10n.chooseFilesTitle
panel.prompt = L10n.choose
// Run modal on the main thread so it doesn't race the drop zone.
panel.begin { [weak self] response in
guard response == .OK else { return }
self?.send(files: panel.urls)
}
}
}