Add LocalSend support with service integration and UI updates

This commit is contained in:
AntonP29 2026-03-21 21:30:41 -07:00
commit f345ddaff8
15 changed files with 1773 additions and 29 deletions

View file

@ -16,6 +16,8 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import AppKit
import Defaults
import SwiftUI
import UniformTypeIdentifiers
@ -24,6 +26,10 @@ struct AirDropView: View {
@State var trigger: UUID = .init()
@State var targeting = false
@Default(.quickShareProvider) var quickShareProvider
@StateObject private var quickShareService = QuickShareService.shared
@State private var showShareSettings = false
@State private var isGearHover = false
var body: some View {
dropArea
@ -40,7 +46,97 @@ struct AirDropView: View {
.fill(.white.opacity(0.1))
.opacity(0.5)
.clipShape(RoundedRectangle(cornerRadius: 10))
.overlay { dropLabel }
.overlay {
ZStack {
dropLabel
// Gear in the bottom-right for quick-share settings
VStack {
Spacer()
HStack {
Spacer()
Button {
quickShareService.ensureDiscovered()
showShareSettings.toggle()
} label: {
Image(systemName: "gearshape")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 14, height: 14)
.padding(8)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(isGearHover ? Color(.windowBackgroundColor).opacity(0.12) : Color.clear)
)
.foregroundColor(isGearHover ? .accentColor : .gray)
.contentShape(Rectangle())
.onHover { over in isGearHover = over }
.help("Quick Share settings")
}
.buttonStyle(.plain)
.popover(isPresented: $showShareSettings) {
VStack(alignment: .leading, spacing: 8) {
Text("Quick Share")
.font(.headline)
Picker("Quick Share Service", selection: $quickShareProvider) {
ForEach(quickShareService.availableProviders, id: \.id) { provider in
HStack(spacing: 8) {
Group {
if let imgData = provider.imageData, let nsImg = NSImage(data: imgData) {
Image(nsImage: nsImg)
.resizable()
.aspectRatio(contentMode: .fit)
} else {
Image(systemName: "square.and.arrow.up")
}
}
.frame(width: 16, height: 16)
.foregroundColor(.accentColor)
Text(provider.id)
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
}
.tag(provider.id)
}
}
.pickerStyle(.menu)
.frame(minWidth: 260)
if let selected = quickShareService.availableProviders.first(where: { $0.id == quickShareProvider }) {
HStack(alignment: .top, spacing: 8) {
Group {
if let imgData = selected.imageData, let nsImg = NSImage(data: imgData) {
Image(nsImage: nsImg)
.resizable()
.frame(width: 20, height: 20)
} else {
Image(systemName: "square.and.arrow.up")
}
}
.foregroundColor(.accentColor)
VStack(alignment: .leading, spacing: 2) {
Text("Currently: \(selected.id)")
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
Text("Files dropped here will be shared via this service")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
}
.padding()
}
.padding(8)
}
}
}
}
.aspectRatio(1, contentMode: .fit)
.contentShape(Rectangle())
}

File diff suppressed because it is too large Load diff

View file

@ -129,6 +129,10 @@ class QuickShareService: ObservableObject {
providers.insert(ad, at: 0)
}
if !providers.contains(where: { $0.id == "LocalSend" }) {
providers.insert(QuickShareProvider(id: "LocalSend", imageData: nil, supportsRawText: true), at: min(1, providers.count))
}
// System Share Menu fallback
if !providers.contains(where: { $0.id == "System Share Menu" }) {
providers.append(QuickShareProvider(id: "System Share Menu", imageData: nil, supportsRawText: true))
@ -137,11 +141,36 @@ class QuickShareService: ObservableObject {
return (providers, services)
}.value
var providers = result.providers
if let idx = providers.firstIndex(where: { $0.id == "LocalSend" }) {
providers[idx].imageData = localSendIconData()
}
self.cachedServices = result.services
self.availableProviders = result.providers
self.availableProviders = providers
self.hasDiscovered = true
self.discoveryTask = nil
}
private func localSendIconData() -> Data? {
guard let icon = NSImage(named: NSImage.Name("LocalSend")) else {
return nil
}
let thumbSize = NSSize(width: 32, height: 32)
let thumb = NSImage(size: thumbSize)
thumb.lockFocus()
icon.draw(in: NSRect(origin: .zero, size: thumbSize),
from: NSRect(origin: .zero, size: icon.size),
operation: .copy,
fraction: 1.0)
thumb.unlockFocus()
guard let tiff = thumb.tiffRepresentation,
let bitmap = NSBitmapImageRep(data: tiff)
else { return nil }
return bitmap.representation(using: .png, properties: [:])
}
// MARK: - File Picker
@MainActor
@ -154,6 +183,9 @@ class QuickShareService: ObservableObject {
isPickerOpen = true
SharingStateManager.shared.beginInteraction()
// Improve interaction in dev/test builds where app activation can be flaky.
NSApp.activate(ignoringOtherApps: true)
let panel = NSOpenPanel()
panel.allowsMultipleSelection = true
panel.canChooseDirectories = true
@ -174,8 +206,12 @@ class QuickShareService: ObservableObject {
}
}
let response = panel.runModal()
completion(response)
if let window = view?.window {
panel.beginSheetModal(for: window, completionHandler: completion)
} else {
let response = panel.runModal()
completion(response)
}
}
// MARK: - Sharing
@ -187,6 +223,21 @@ class QuickShareService: ObservableObject {
// Start security-scoped access for all file URLs
sharingAccessingURLs = fileURLs.filter { $0.startAccessingSecurityScopedResource() }
if provider.id == "LocalSend" {
SharingStateManager.shared.beginInteraction()
defer {
SharingStateManager.shared.endInteraction()
}
do {
try await LocalSendService.shared.send(items: items)
} catch {
NSAlert.popError(error)
}
stopSharingAccessingURLs()
return
}
// Setup lifecycle delegate to keep notch open during picker/service
let delegate = SharingStateManager.shared.makeDelegate { [weak self] in
self?.lifecycleDelegate = nil
@ -210,6 +261,7 @@ class QuickShareService: ObservableObject {
}
private func stopSharingAccessingURLs() {
guard !sharingAccessingURLs.isEmpty else { return }
NSLog("Stopping sharing access to URLs")
for url in sharingAccessingURLs {
url.stopAccessingSecurityScopedResource()

View file

@ -49,19 +49,25 @@ final class ShelfStateViewModel: ObservableObject {
var merged = items
// Deduplicate by identityKey while preserving order (existing first)
var seen: Set<String> = Set(merged.map { $0.identityKey })
var addedIDs: [String] = []
for it in newItems {
let key = it.identityKey
if !seen.contains(key) {
merged.append(it)
seen.insert(key)
addedIDs.append(it.id.uuidString)
}
}
items = merged
if !addedIDs.isEmpty {
ExtensionRPCServer.shared.notifyShelfItemsChanged(itemIDs: addedIDs, action: "added")
}
}
func remove(_ item: ShelfItem) {
item.cleanupStoredData()
items.removeAll { $0.id == item.id }
ExtensionRPCServer.shared.notifyShelfItemsChanged(itemIDs: [item.id.uuidString], action: "removed")
}
func updateBookmark(for item: ShelfItem, bookmark: Data) {

View file

@ -28,7 +28,11 @@ import UniformTypeIdentifiers
struct FileShareView: View {
@EnvironmentObject private var vm: DynamicIslandViewModel
@StateObject private var quickShare = QuickShareService.shared
@StateObject private var localSend = LocalSendService.shared
@Default(.quickShareProvider) var quickShareProvider: String
@State private var showQuickSharePopover = false
@State private var isGearHover = false
@State private var autoCloseToken = UUID()
@State private var hostView: NSView?
@State private var interactionNonce: UUID = .init()
@ -38,10 +42,17 @@ struct FileShareView: View {
quickShare.availableProviders.first(where: { $0.id == quickShareProvider }) ?? QuickShareProvider(id: "System Share Menu", imageData: nil, supportsRawText: true)
}
private var notchToggleProviders: [QuickShareProvider] {
quickShare.availableProviders.filter { $0.id == "AirDrop" || $0.id == "LocalSend" }
}
var body: some View {
dropArea
.background(NSViewHost(view: $hostView))
.onAppear { quickShare.ensureDiscovered() }
.onAppear {
quickShare.ensureDiscovered()
localSend.startDiscovery()
}
.onDrop(of: [.fileURL, .url, .utf8PlainText, .plainText, .data, .image], isTargeted: $vm.dropZoneTargeting) { providers in
interactionNonce = .init()
vm.dropEvent = true
@ -49,8 +60,10 @@ struct FileShareView: View {
return true
}
.onTapGesture {
Task {
await handleClick()
guard quickShare.availableProviders.first(where: { $0.id == quickShareProvider }) != nil else { return }
// Only open picker on taps when AirDrop or LocalSend is selected
if quickShareProvider == "AirDrop" || quickShareProvider == "LocalSend" {
Task { await handleClick() }
}
}
}
@ -85,12 +98,16 @@ struct FileShareView: View {
if let imgData = selectedProvider.imageData, let nsImg = NSImage(data: imgData) {
Image(nsImage: nsImg)
.resizable()
.aspectRatio(contentMode: .fit)
.scaledToFit()
.frame(width: 34, height: 34)
.clipped()
} else {
Image(systemName: "square.and.arrow.up")
.resizable()
.scaledToFit()
.frame(width: 34, height: 34)
}
}
.frame(width: 34, height: 34)
.foregroundStyle(
vm.dropZoneTargeting ? Color.accentColor : Color.gray
)
@ -104,11 +121,159 @@ struct FileShareView: View {
.font(.system(.headline, design: .rounded))
.foregroundColor(.white.opacity(0.8))
.multilineTextAlignment(.center)
.lineLimit(1)
.truncationMode(.tail)
.frame(maxWidth: .infinity)
// Share mode selector moved to the notch tab gear icon
if selectedProvider.id == "LocalSend" {
HStack(spacing: 8) {
Menu {
if localSend.devices.isEmpty {
Text("No devices found")
} else {
ForEach(localSend.devices) { device in
Button(device.displayName) {
localSend.selectedDeviceID = device.id
}
}
}
} label: {
HStack(spacing: 6) {
Image(systemName: "iphone.and.arrow.forward")
.font(.system(size: 11, weight: .semibold))
Text(localSend.devices.first(where: { $0.id == localSend.selectedDeviceID })?.displayName ?? "Select device")
.font(.system(size: 11, weight: .medium))
.lineLimit(1)
}
.foregroundStyle(.white.opacity(0.9))
}
Button {
localSend.refreshDeviceScan()
} label: {
Image(systemName: "arrow.clockwise")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(.white.opacity(0.9))
.rotationEffect(.degrees(localSend.isRefreshing ? 360 : 0))
.animation(
localSend.isRefreshing
? .linear(duration: 0.85).repeatForever(autoreverses: false)
: .easeOut(duration: 0.2),
value: localSend.isRefreshing
)
}
.buttonStyle(.plain)
.disabled(localSend.isRefreshing)
.help("Refresh devices")
}
}
}
.padding(18)
.frame(maxWidth: .infinity)
// Gear pinned to top-left corner of quick share box
VStack {
HStack {
Button {
// Ensure notch stays open immediately when opening the selector
vm.setAutoCloseSuppression(true, token: autoCloseToken)
quickShare.ensureDiscovered()
showQuickSharePopover.toggle()
} label: {
Image(systemName: "gear")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 14, height: 14)
.padding(8)
.background(RoundedRectangle(cornerRadius: 8).fill(isGearHover ? Color(.windowBackgroundColor).opacity(0.12) : Color.clear))
.foregroundColor(isGearHover ? .accentColor : .gray)
}
.buttonStyle(PlainButtonStyle())
.onHover { hovering in
isGearHover = hovering
vm.setAutoCloseSuppression(hovering, token: autoCloseToken)
}
.popover(isPresented: $showQuickSharePopover, arrowEdge: .bottom) {
VStack(alignment: .leading, spacing: 8) {
Text("Quick Share")
.font(.headline)
Picker("Quick Share Service", selection: $quickShareProvider) {
ForEach(quickShare.availableProviders, id: \.id) { provider in
HStack(spacing: 8) {
Group {
if let imgData = provider.imageData, let nsImg = NSImage(data: imgData) {
Image(nsImage: nsImg)
.resizable()
.aspectRatio(contentMode: .fit)
} else {
Image(systemName: "square.and.arrow.up")
}
}
.frame(width: 16, height: 16)
.foregroundColor(.accentColor)
Text(provider.id)
.lineLimit(1)
.truncationMode(.tail)
.layoutPriority(1)
.fixedSize(horizontal: true, vertical: false)
}
.tag(provider.id)
}
}
.pickerStyle(.menu)
.frame(minWidth: 260)
if let selected = quickShare.availableProviders.first(where: { $0.id == quickShareProvider }) {
HStack(alignment: .top, spacing: 8) {
Group {
if let imgData = selected.imageData, let nsImg = NSImage(data: imgData) {
Image(nsImage: nsImg)
.resizable()
.frame(width: 20, height: 20)
} else {
Image(systemName: "square.and.arrow.up")
}
}
.foregroundColor(.accentColor)
VStack(alignment: .leading, spacing: 2) {
Text("Currently: \(selected.id)")
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.tail)
.layoutPriority(1)
.fixedSize(horizontal: true, vertical: false)
Text("Files shared from the shelf will use this service")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
}
.padding()
.onAppear { vm.setAutoCloseSuppression(true, token: autoCloseToken) }
.onDisappear {
// Delay clearing suppression slightly so the close click doesn't immediately close the notch
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
vm.setAutoCloseSuppression(false, token: autoCloseToken)
}
}
.onHover { hovering in vm.setAutoCloseSuppression(hovering, token: autoCloseToken) }
}
.padding(.leading, 8)
.padding(.top, 8)
Spacer()
}
Spacer()
}
// Loading overlay
if isProcessing || quickShare.isPickerOpen {
@ -120,6 +285,38 @@ struct FileShareView: View {
.scaleEffect(0.8)
)
}
if selectedProvider.id == "LocalSend" && localSend.isSending {
VStack {
HStack {
Spacer()
ZStack {
Circle()
.stroke(Color.white.opacity(0.16), lineWidth: 3)
Circle()
.trim(from: 0, to: min(max(localSend.sendProgress, 0), 1))
.stroke(
Color.white,
style: StrokeStyle(lineWidth: 3, lineCap: .round)
)
.rotationEffect(.degrees(-90))
.animation(.easeInOut(duration: 0.2), value: localSend.sendProgress)
if localSend.sendProgress > 0.99 {
Image(systemName: "checkmark")
.font(.system(size: 9, weight: .bold))
.foregroundColor(.white)
}
}
.frame(width: 24, height: 24)
.padding(.top, 10)
.padding(.trailing, 10)
}
Spacer()
}
.transition(.opacity)
}
}
.contentShape(RoundedRectangle(cornerRadius: 12))
}

View file

@ -23,6 +23,7 @@
import AtollExtensionKit
import SwiftUI
import Defaults
import AppKit
struct TabModel: Identifiable {
let id: String
@ -45,6 +46,9 @@ struct TabModel: Identifiable {
struct TabSelectionView: View {
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
@ObservedObject private var extensionNotchExperienceManager = ExtensionNotchExperienceManager.shared
@StateObject private var quickShareService = QuickShareService.shared
@Default(.quickShareProvider) private var quickShareProvider
@State private var showQuickSharePopover = false
@Default(.enableTimerFeature) var enableTimerFeature
@Default(.enableStatsFeature) var enableStatsFeature
@Default(.enableColorPickerFeature) var enableColorPickerFeature
@ -106,9 +110,11 @@ struct TabSelectionView: View {
}
var body: some View {
HStack(spacing: 0) {
ForEach(tabs) { tab in
ForEach(Array(tabs.enumerated()), id: \.element.id) { idx, tab in
let isSelected = isSelected(tab)
let activeAccent = tab.accentColor ?? .white
// Render the tab button
TabButton(label: tab.label, icon: tab.icon, selected: isSelected) {
if tab.view == .extensionExperience {
coordinator.selectedExtensionExperienceID = tab.experienceID
@ -130,6 +136,8 @@ struct TabSelectionView: View {
.hidden()
}
}
}
}
.animation(.smooth(duration: 0.3), value: coordinator.currentView)