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

@ -27,7 +27,6 @@
7EE1D73D2E6B1E0B00997219 /* ApplicationServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7EE1D73C2E6B1E0B00997219 /* ApplicationServices.framework */; }; 7EE1D73D2E6B1E0B00997219 /* ApplicationServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7EE1D73C2E6B1E0B00997219 /* ApplicationServices.framework */; };
7EE1D73F2E6B1E1200997219 /* IOKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7EE1D73E2E6B1E1200997219 /* IOKit.framework */; }; 7EE1D73F2E6B1E1200997219 /* IOKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7EE1D73E2E6B1E1200997219 /* IOKit.framework */; };
7EF19C172F577040005B9929 /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = 7EF19C162F577040005B9929 /* SwiftTerm */; }; 7EF19C172F577040005B9929 /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = 7EF19C162F577040005B9929 /* SwiftTerm */; };
7EF1D4B92FFFCE3C008D2C32 /* ExtensionEventBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EF1D4B82FFFCE3C008D2C32 /* ExtensionEventBridge.swift */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXCopyFilesBuildPhase section */ /* Begin PBXCopyFilesBuildPhase section */
@ -54,12 +53,18 @@
7E9911AF2EFD7D1B002EDFD5 /* Reindeer.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = Reindeer.json; sourceTree = "<group>"; }; 7E9911AF2EFD7D1B002EDFD5 /* Reindeer.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = Reindeer.json; sourceTree = "<group>"; };
7EE1D73C2E6B1E0B00997219 /* ApplicationServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ApplicationServices.framework; path = System/Library/Frameworks/ApplicationServices.framework; sourceTree = SDKROOT; }; 7EE1D73C2E6B1E0B00997219 /* ApplicationServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ApplicationServices.framework; path = System/Library/Frameworks/ApplicationServices.framework; sourceTree = SDKROOT; };
7EE1D73E2E6B1E1200997219 /* IOKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = IOKit.framework; path = System/Library/Frameworks/IOKit.framework; sourceTree = SDKROOT; }; 7EE1D73E2E6B1E1200997219 /* IOKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = IOKit.framework; path = System/Library/Frameworks/IOKit.framework; sourceTree = SDKROOT; };
7EF1D4B82FFFCE3C008D2C32 /* ExtensionEventBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ExtensionEventBridge.swift; path = DynamicIsland/managers/Extensions/ExtensionEventBridge.swift; sourceTree = SOURCE_ROOT; };
F4C1DD09EC134302938E84F6 /* MediaRemoteAdapter.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MediaRemoteAdapter.framework; path = Frameworks/MediaRemoteAdapter.framework; sourceTree = SOURCE_ROOT; }; F4C1DD09EC134302938E84F6 /* MediaRemoteAdapter.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MediaRemoteAdapter.framework; path = Frameworks/MediaRemoteAdapter.framework; sourceTree = SOURCE_ROOT; };
F54406E72CD4987A00C7AFD9 /* Atoll.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Atoll.app; sourceTree = BUILT_PRODUCTS_DIR; }; F54406E72CD4987A00C7AFD9 /* Atoll.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Atoll.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
7EA69B212F5B100000EB2D16 /* Exceptions for "DynamicIsland" folder in "DynamicIsland" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = F54406E62CD4987A00C7AFD9 /* DynamicIsland */;
};
7EA69B1E2F5AE61200EB2D16 /* Exceptions for "Contents" folder in "DynamicIsland" target */ = { 7EA69B1E2F5AE61200EB2D16 /* Exceptions for "Contents" folder in "DynamicIsland" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet; isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = ( membershipExceptions = (
@ -94,6 +99,9 @@
}; };
F54406E92CD4987A00C7AFD9 /* DynamicIsland */ = { F54406E92CD4987A00C7AFD9 /* DynamicIsland */ = {
isa = PBXFileSystemSynchronizedRootGroup; isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
7EA69B212F5B100000EB2D16 /* Exceptions for "DynamicIsland" folder in "DynamicIsland" target */,
);
path = DynamicIsland; path = DynamicIsland;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@ -123,14 +131,6 @@
/* End PBXFrameworksBuildPhase section */ /* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */ /* Begin PBXGroup section */
7E07AFF12F181892003A8B1B /* Recovered References */ = {
isa = PBXGroup;
children = (
7EF1D4B82FFFCE3C008D2C32 /* ExtensionEventBridge.swift */,
);
name = "Recovered References";
sourceTree = "<group>";
};
7E81303F2E3FB40500E08F3F /* mediaremote-adapter */ = { 7E81303F2E3FB40500E08F3F /* mediaremote-adapter */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -173,7 +173,6 @@
7E81303F2E3FB40500E08F3F /* mediaremote-adapter */, 7E81303F2E3FB40500E08F3F /* mediaremote-adapter */,
F54406E92CD4987A00C7AFD9 /* DynamicIsland */, F54406E92CD4987A00C7AFD9 /* DynamicIsland */,
F54406E82CD4987A00C7AFD9 /* Products */, F54406E82CD4987A00C7AFD9 /* Products */,
7E07AFF12F181892003A8B1B /* Recovered References */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@ -296,6 +295,7 @@
/* Begin PBXShellScriptBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */
7EFAA8AA2F6F12F3000A99E8 /* ShellScript */ = { 7EFAA8AA2F6F12F3000A99E8 /* ShellScript */ = {
isa = PBXShellScriptBuildPhase; isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
); );
@ -318,7 +318,6 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
7EF1D4B92FFFCE3C008D2C32 /* ExtensionEventBridge.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@ -451,13 +450,11 @@
AUTOMATION_APPLE_EVENTS = NO; AUTOMATION_APPLE_EVENTS = NO;
CODE_SIGN_ENTITLEMENTS = DynamicIsland/DynamicIsland.entitlements; CODE_SIGN_ENTITLEMENTS = DynamicIsland/DynamicIsland.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Developer ID Application"; CODE_SIGN_STYLE = Automatic;
CODE_SIGN_STYLE = Manual;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 11; CURRENT_PROJECT_VERSION = 11;
DEVELOPMENT_ASSET_PATHS = "\"DynamicIsland/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"DynamicIsland/Preview Content\"";
DEVELOPMENT_TEAM = ""; DEVELOPMENT_TEAM = 5W5BDQQ32W;
"DEVELOPMENT_TEAM[sdk=macosx*]" = 9Y64TRM77N;
ENABLE_APP_SANDBOX = NO; ENABLE_APP_SANDBOX = NO;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
@ -529,7 +526,7 @@
CURRENT_PROJECT_VERSION = 11; CURRENT_PROJECT_VERSION = 11;
DEVELOPMENT_ASSET_PATHS = "\"DynamicIsland/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"DynamicIsland/Preview Content\"";
DEVELOPMENT_TEAM = ""; DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=macosx*]" = 9Y64TRM77N; "DEVELOPMENT_TEAM[sdk=macosx*]" = 5W5BDQQ32W;
ENABLE_APP_SANDBOX = NO; ENABLE_APP_SANDBOX = NO;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;

View file

@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "localsend.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

View file

@ -1549,7 +1549,6 @@ struct ContentView: View {
coordinator.currentView = .shelf coordinator.currentView = .shelf
openNotch() openNotch()
} else if !isTargeted { } else if !isTargeted {
print("DROP EVENT", vm.dropEvent)
if vm.dropEvent { if vm.dropEvent {
vm.dropEvent = false vm.dropEvent = false
return return

View file

@ -7716,6 +7716,10 @@
} }
} }
}, },
"Currently: %@" : {
"comment" : "A label inside the quick share settings view, describing the currently selected quick share service.",
"isCommentAutoGenerated" : true
},
"Cursor" : { "Cursor" : {
}, },
@ -11260,6 +11264,10 @@
"comment" : "Display name for a \"File\" clipboard item type.", "comment" : "Display name for a \"File\" clipboard item type.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Files dropped here will be shared via this service" : {
"comment" : "A description of what happens when files are dropped into the AirDrop view.",
"isCommentAutoGenerated" : true
},
"Files dropped on the shelf will be shared via this service" : { "Files dropped on the shelf will be shared via this service" : {
"comment" : "A description below the currently selected quick share provider, explaining that files dropped on the shelf will be shared via that provider.", "comment" : "A description below the currently selected quick share provider, explaining that files dropped on the shelf will be shared via that provider.",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
@ -11278,6 +11286,10 @@
} }
} }
}, },
"Files shared from the shelf will use this service" : {
"comment" : "A description of how files shared from the shelf will be shared.",
"isCommentAutoGenerated" : true
},
"Fill Notch" : { "Fill Notch" : {
"localizations" : { "localizations" : {
"pt-BR" : { "pt-BR" : {
@ -17415,6 +17427,10 @@
} }
} }
}, },
"No devices found" : {
"comment" : "A message displayed when there are no local send devices available.",
"isCommentAutoGenerated" : true
},
"No events" : { "No events" : {
"comment" : "A description displayed when there are no events in the calendar.", "comment" : "A description displayed when there are no events in the calendar.",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
@ -19411,6 +19427,10 @@
} }
} }
}, },
"Quick Share settings" : {
"comment" : "A help text for the \"Quick Share settings\" button in the AirDrop view.",
"isCommentAutoGenerated" : true
},
"Quit" : { "Quit" : {
"localizations" : { "localizations" : {
"ar" : { "ar" : {
@ -19787,6 +19807,9 @@
"Refresh detection" : { "Refresh detection" : {
"comment" : "A button label that refreshes the detection status of BetterDisplay.", "comment" : "A button label that refreshes the detection status of BetterDisplay.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
},
"Refresh devices" : {
}, },
"Release name" : { "Release name" : {
"localizations" : { "localizations" : {

View file

@ -16,6 +16,8 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import AppKit
import Defaults
import SwiftUI import SwiftUI
import UniformTypeIdentifiers import UniformTypeIdentifiers
@ -24,6 +26,10 @@ struct AirDropView: View {
@State var trigger: UUID = .init() @State var trigger: UUID = .init()
@State var targeting = false @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 { var body: some View {
dropArea dropArea
@ -40,7 +46,97 @@ struct AirDropView: View {
.fill(.white.opacity(0.1)) .fill(.white.opacity(0.1))
.opacity(0.5) .opacity(0.5)
.clipShape(RoundedRectangle(cornerRadius: 10)) .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) .aspectRatio(1, contentMode: .fit)
.contentShape(Rectangle()) .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) 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 // System Share Menu fallback
if !providers.contains(where: { $0.id == "System Share Menu" }) { if !providers.contains(where: { $0.id == "System Share Menu" }) {
providers.append(QuickShareProvider(id: "System Share Menu", imageData: nil, supportsRawText: true)) providers.append(QuickShareProvider(id: "System Share Menu", imageData: nil, supportsRawText: true))
@ -137,12 +141,37 @@ class QuickShareService: ObservableObject {
return (providers, services) return (providers, services)
}.value }.value
var providers = result.providers
if let idx = providers.firstIndex(where: { $0.id == "LocalSend" }) {
providers[idx].imageData = localSendIconData()
}
self.cachedServices = result.services self.cachedServices = result.services
self.availableProviders = result.providers self.availableProviders = providers
self.hasDiscovered = true self.hasDiscovered = true
self.discoveryTask = nil 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 // MARK: - File Picker
@MainActor @MainActor
func showFilePicker(for provider: QuickShareProvider, from view: NSView?) async { func showFilePicker(for provider: QuickShareProvider, from view: NSView?) async {
@ -154,6 +183,9 @@ class QuickShareService: ObservableObject {
isPickerOpen = true isPickerOpen = true
SharingStateManager.shared.beginInteraction() SharingStateManager.shared.beginInteraction()
// Improve interaction in dev/test builds where app activation can be flaky.
NSApp.activate(ignoringOtherApps: true)
let panel = NSOpenPanel() let panel = NSOpenPanel()
panel.allowsMultipleSelection = true panel.allowsMultipleSelection = true
panel.canChooseDirectories = true panel.canChooseDirectories = true
@ -174,9 +206,13 @@ class QuickShareService: ObservableObject {
} }
} }
if let window = view?.window {
panel.beginSheetModal(for: window, completionHandler: completion)
} else {
let response = panel.runModal() let response = panel.runModal()
completion(response) completion(response)
} }
}
// MARK: - Sharing // MARK: - Sharing
@MainActor @MainActor
@ -187,6 +223,21 @@ class QuickShareService: ObservableObject {
// Start security-scoped access for all file URLs // Start security-scoped access for all file URLs
sharingAccessingURLs = fileURLs.filter { $0.startAccessingSecurityScopedResource() } 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 // Setup lifecycle delegate to keep notch open during picker/service
let delegate = SharingStateManager.shared.makeDelegate { [weak self] in let delegate = SharingStateManager.shared.makeDelegate { [weak self] in
self?.lifecycleDelegate = nil self?.lifecycleDelegate = nil
@ -210,6 +261,7 @@ class QuickShareService: ObservableObject {
} }
private func stopSharingAccessingURLs() { private func stopSharingAccessingURLs() {
guard !sharingAccessingURLs.isEmpty else { return }
NSLog("Stopping sharing access to URLs") NSLog("Stopping sharing access to URLs")
for url in sharingAccessingURLs { for url in sharingAccessingURLs {
url.stopAccessingSecurityScopedResource() url.stopAccessingSecurityScopedResource()

View file

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

View file

@ -28,7 +28,11 @@ import UniformTypeIdentifiers
struct FileShareView: View { struct FileShareView: View {
@EnvironmentObject private var vm: DynamicIslandViewModel @EnvironmentObject private var vm: DynamicIslandViewModel
@StateObject private var quickShare = QuickShareService.shared @StateObject private var quickShare = QuickShareService.shared
@StateObject private var localSend = LocalSendService.shared
@Default(.quickShareProvider) var quickShareProvider: String @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 hostView: NSView?
@State private var interactionNonce: UUID = .init() @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) 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 { var body: some View {
dropArea dropArea
.background(NSViewHost(view: $hostView)) .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 .onDrop(of: [.fileURL, .url, .utf8PlainText, .plainText, .data, .image], isTargeted: $vm.dropZoneTargeting) { providers in
interactionNonce = .init() interactionNonce = .init()
vm.dropEvent = true vm.dropEvent = true
@ -49,8 +60,10 @@ struct FileShareView: View {
return true return true
} }
.onTapGesture { .onTapGesture {
Task { guard quickShare.availableProviders.first(where: { $0.id == quickShareProvider }) != nil else { return }
await handleClick() // 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) { if let imgData = selectedProvider.imageData, let nsImg = NSImage(data: imgData) {
Image(nsImage: nsImg) Image(nsImage: nsImg)
.resizable() .resizable()
.aspectRatio(contentMode: .fit) .scaledToFit()
.frame(width: 34, height: 34)
.clipped()
} else { } else {
Image(systemName: "square.and.arrow.up") Image(systemName: "square.and.arrow.up")
} .resizable()
} .scaledToFit()
.frame(width: 34, height: 34) .frame(width: 34, height: 34)
}
}
.foregroundStyle( .foregroundStyle(
vm.dropZoneTargeting ? Color.accentColor : Color.gray vm.dropZoneTargeting ? Color.accentColor : Color.gray
) )
@ -104,12 +121,160 @@ struct FileShareView: View {
.font(.system(.headline, design: .rounded)) .font(.system(.headline, design: .rounded))
.foregroundColor(.white.opacity(0.8)) .foregroundColor(.white.opacity(0.8))
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.lineLimit(1)
.truncationMode(.tail)
.frame(maxWidth: .infinity) .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) .padding(18)
.frame(maxWidth: .infinity) .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 // Loading overlay
if isProcessing || quickShare.isPickerOpen { if isProcessing || quickShare.isPickerOpen {
RoundedRectangle(cornerRadius: 12) RoundedRectangle(cornerRadius: 12)
@ -120,6 +285,38 @@ struct FileShareView: View {
.scaleEffect(0.8) .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)) .contentShape(RoundedRectangle(cornerRadius: 12))
} }

View file

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

View file

@ -111,6 +111,8 @@ final class ExtensionAuthorizationManager: ObservableObject {
record.widgetTimestamps.append(now) record.widgetTimestamps.append(now)
case .notchExperiences: case .notchExperiences:
record.notchExperienceTimestamps.append(now) record.notchExperienceTimestamps.append(now)
case .fileSharing:
record.activityTimestamps.append(now)
} }
record.activityTimestamps = flushOldTimestamps(record.activityTimestamps) record.activityTimestamps = flushOldTimestamps(record.activityTimestamps)
record.widgetTimestamps = flushOldTimestamps(record.widgetTimestamps) record.widgetTimestamps = flushOldTimestamps(record.widgetTimestamps)

View file

@ -143,12 +143,14 @@ enum ExtensionPermissionScope: String, CaseIterable, Codable, Defaults.Serializa
case liveActivities case liveActivities
case lockScreenWidgets case lockScreenWidgets
case notchExperiences case notchExperiences
case fileSharing
var displayName: String { var displayName: String {
switch self { switch self {
case .liveActivities: return "Live Activities" case .liveActivities: return "Live Activities"
case .lockScreenWidgets: return "Lock Screen Widgets" case .lockScreenWidgets: return "Lock Screen Widgets"
case .notchExperiences: return "Notch Experiences" case .notchExperiences: return "Notch Experiences"
case .fileSharing: return "File Sharing"
} }
} }
} }
@ -885,6 +887,7 @@ extension Defaults.Keys {
static let dynamicShelf = Key<Bool>("dynamicShelf", default: true) static let dynamicShelf = Key<Bool>("dynamicShelf", default: true)
static let openShelfByDefault = Key<Bool>("openShelfByDefault", default: true) static let openShelfByDefault = Key<Bool>("openShelfByDefault", default: true)
static let quickShareProvider = Key<String>("quickShareProvider", default: "AirDrop") static let quickShareProvider = Key<String>("quickShareProvider", default: "AirDrop")
static let localSendSelectedDeviceID = Key<String>("localSendSelectedDeviceID", default: "")
static let copyOnDrag = Key<Bool>("copyOnDrag", default: false) static let copyOnDrag = Key<Bool>("copyOnDrag", default: false)
static let autoRemoveShelfItems = Key<Bool>("autoRemoveShelfItems", default: false) static let autoRemoveShelfItems = Key<Bool>("autoRemoveShelfItems", default: false)
static let expandedDragDetection = Key<Bool>("expandedDragDetection", default: true) static let expandedDragDetection = Key<Bool>("expandedDragDetection", default: true)
@ -1002,6 +1005,7 @@ extension Defaults.Keys {
static let extensionLiveActivityCapacity = Key<Int>("extensionLiveActivityCapacity", default: 4) static let extensionLiveActivityCapacity = Key<Int>("extensionLiveActivityCapacity", default: 4)
static let extensionLockScreenWidgetCapacity = Key<Int>("extensionLockScreenWidgetCapacity", default: 4) static let extensionLockScreenWidgetCapacity = Key<Int>("extensionLockScreenWidgetCapacity", default: 4)
static let extensionNotchExperienceCapacity = Key<Int>("extensionNotchExperienceCapacity", default: 2) static let extensionNotchExperienceCapacity = Key<Int>("extensionNotchExperienceCapacity", default: 2)
static let enableExtensionFileSharing = Key<Bool>("enableExtensionFileSharing", default: true)
// MARK: Keyboard Shortcuts // MARK: Keyboard Shortcuts
static let enableShortcuts = Key<Bool>("enableShortcuts", default: true) static let enableShortcuts = Key<Bool>("enableShortcuts", default: true)

View file

@ -29,6 +29,7 @@ final class ExtensionRPCServer {
private var listener: NWListener? private var listener: NWListener?
private var connections: [UUID: RPCClientConnection] = [:] private var connections: [UUID: RPCClientConnection] = [:]
private var shelfSubscribers: Set<String> = [] // bundleIdentifiers subscribed to shelf events
private let port: UInt16 = 9020 private let port: UInt16 = 9020
private let queue = DispatchQueue(label: "com.ebullioscopic.Atoll.rpc.server", qos: .userInitiated) private let queue = DispatchQueue(label: "com.ebullioscopic.Atoll.rpc.server", qos: .userInitiated)
private let decoder = JSONDecoder() private let decoder = JSONDecoder()
@ -78,6 +79,7 @@ final class ExtensionRPCServer {
conn.connection.cancel() conn.connection.cancel()
} }
connections.removeAll() connections.removeAll()
shelfSubscribers.removeAll()
logDiagnostics("RPC server stopped") logDiagnostics("RPC server stopped")
} }
@ -127,6 +129,29 @@ final class ExtensionRPCServer {
) )
} }
// MARK: - Shelf Event Subscriptions
func registerShelfSubscription(for bundleIdentifier: String) {
shelfSubscribers.insert(bundleIdentifier)
logDiagnostics("Registered shelf subscription for \(bundleIdentifier)")
}
func notifyShelfItemsChanged(itemIDs: [String], action: String) {
guard !shelfSubscribers.isEmpty else { return }
let params: [String: RPCValue] = [
"action": .string(action),
"itemIDs": .array(itemIDs.map { .string($0) })
]
for subscriber in shelfSubscribers {
sendNotification(
to: subscriber,
method: "atoll.shelfItemsDidChange",
params: params
)
}
logDiagnostics("Notified \(shelfSubscribers.count) subscriber(s) of shelf change (\(action), \(itemIDs.count) items)")
}
// MARK: - Connection Handling // MARK: - Connection Handling
private func handleListenerState(_ state: NWListener.State) { private func handleListenerState(_ state: NWListener.State) {

View file

@ -17,8 +17,10 @@
*/ */
import Foundation import Foundation
import AppKit
import Defaults import Defaults
import AtollExtensionKit import AtollExtensionKit
import UniformTypeIdentifiers
/// Handles JSON-RPC method calls for a single WebSocket connection. /// Handles JSON-RPC method calls for a single WebSocket connection.
/// Mirrors the functionality of `ExtensionXPCService` but uses JSON-RPC transport. /// Mirrors the functionality of `ExtensionXPCService` but uses JSON-RPC transport.
@ -94,6 +96,25 @@ final class ExtensionRPCService {
case "atoll.dismissNotchExperience": case "atoll.dismissNotchExperience":
result = handleDismissNotchExperience(params: request.params, id: request.id) result = handleDismissNotchExperience(params: request.params, id: request.id)
// MARK: File Sharing
case "atoll.getShelfItems":
result = handleGetShelfItems(params: request.params, id: request.id)
case "atoll.getShelfItemData":
result = handleGetShelfItemData(params: request.params, id: request.id)
case "atoll.showFilePicker":
result = handleShowFilePicker(params: request.params, id: request.id)
case "atoll.shareShelfItems":
result = handleShareShelfItems(params: request.params, id: request.id)
case "atoll.addFilesToShelf":
result = handleAddFilesToShelf(params: request.params, id: request.id)
case "atoll.subscribeShelfEvents":
result = handleSubscribeShelfEvents(params: request.params, id: request.id)
default: default:
result = RPCErrorResponse( result = RPCErrorResponse(
error: RPCErrorObject(code: RPCErrorCode.methodNotFound, message: "Method not found: \(request.method)"), error: RPCErrorObject(code: RPCErrorCode.methodNotFound, message: "Method not found: \(request.method)"),
@ -327,7 +348,7 @@ final class ExtensionRPCService {
case .duplicateIdentifier: code = RPCErrorCode.descriptorInvalid case .duplicateIdentifier: code = RPCErrorCode.descriptorInvalid
} }
return RPCErrorResponse( return RPCErrorResponse(
error: RPCErrorObject(code: code, message: error.localizedDescription ?? "Unknown error"), error: RPCErrorObject(code: code, message: error.localizedDescription),
id: id id: id
) )
} }
@ -337,6 +358,232 @@ final class ExtensionRPCService {
Logger.log(message, category: .extensions) Logger.log(message, category: .extensions)
} }
// MARK: - File Sharing Authorization Check
private func checkFileSharingAuthorization(id: String) -> RPCErrorResponse? {
guard Defaults[.enableThirdPartyExtensions] else {
return errorResponse(code: RPCErrorCode.featureDisabled, message: "Extensions are disabled", id: id)
}
guard Defaults[.enableExtensionFileSharing] else {
return errorResponse(code: RPCErrorCode.featureDisabled, message: "Extension file sharing is disabled", id: id)
}
let entry = authorizationManager.authorizationEntry(for: bundleIdentifier)
guard entry?.isAuthorized == true else {
return errorResponse(code: RPCErrorCode.unauthorized, message: "Extension not authorized", id: id)
}
guard entry?.allowedScopes.contains(.fileSharing) == true else {
return errorResponse(code: RPCErrorCode.unauthorized, message: "File sharing scope not granted", id: id)
}
return nil
}
// MARK: - File Sharing Handlers
private func handleGetShelfItems(params: RPCParams?, id: String) -> Codable {
if let err = checkFileSharingAuthorization(id: id) { return err }
let items = ShelfStateViewModel.shared.items
var result: [RPCValue] = []
for item in items {
var entry: [String: RPCValue] = [
"id": .string(item.id.uuidString),
"name": .string(item.displayName)
]
switch item.kind {
case .file(let bookmark):
entry["kind"] = .string("file")
if let url = Bookmark(data: bookmark).resolveURL() {
entry["path"] = .string(url.path)
if let attrs = try? FileManager.default.attributesOfItem(atPath: url.path),
let size = attrs[.size] as? Int {
entry["size"] = .int(size)
}
}
case .text(let string):
entry["kind"] = .string("text")
entry["textContent"] = .string(string)
case .link(let url):
entry["kind"] = .string("link")
entry["url"] = .string(url.absoluteString)
}
result.append(.object(entry))
}
logDiagnostics("RPC: getShelfItems returned \(result.count) items for \(bundleIdentifier)")
return RPCSuccessResponse(result: ["items": .array(result)], id: id)
}
private func handleGetShelfItemData(params: RPCParams?, id: String) -> Codable {
if let err = checkFileSharingAuthorization(id: id) { return err }
guard let itemID = params?["itemID"]?.stringValue,
let uuid = UUID(uuidString: itemID) else {
return errorResponse(code: RPCErrorCode.invalidParams, message: "Missing or invalid itemID", id: id)
}
guard let item = ShelfStateViewModel.shared.items.first(where: { $0.id == uuid }) else {
return errorResponse(code: RPCErrorCode.invalidParams, message: "Item not found", id: id)
}
switch item.kind {
case .file(let bookmark):
guard let url = Bookmark(data: bookmark).resolveURL() else {
return errorResponse(code: RPCErrorCode.internalError, message: "Cannot resolve file bookmark", id: id)
}
guard let fileData = url.accessSecurityScopedResource(accessor: { try? Data(contentsOf: $0) }) else {
return errorResponse(code: RPCErrorCode.internalError, message: "Cannot read file data", id: id)
}
let base64 = fileData.base64EncodedString()
logDiagnostics("RPC: getShelfItemData returned \(fileData.count) bytes for \(bundleIdentifier)")
return RPCSuccessResponse(result: [
"data": .string(base64),
"fileName": .string(url.lastPathComponent),
"mimeType": .string(url.mimeType ?? "application/octet-stream")
], id: id)
case .text(let string):
return RPCSuccessResponse(result: [
"data": .string(Data(string.utf8).base64EncodedString()),
"fileName": .string("text.txt"),
"mimeType": .string("text/plain")
], id: id)
case .link(let url):
return RPCSuccessResponse(result: [
"data": .string(Data(url.absoluteString.utf8).base64EncodedString()),
"fileName": .string("link.url"),
"mimeType": .string("text/uri-list")
], id: id)
}
}
private func handleShowFilePicker(params: RPCParams?, id: String) -> Codable {
if let err = checkFileSharingAuthorization(id: id) { return err }
let panel = NSOpenPanel()
panel.allowsMultipleSelection = true
panel.canChooseDirectories = true
panel.canChooseFiles = true
panel.title = "Select files to share"
let response = panel.runModal()
guard response == .OK, !panel.urls.isEmpty else {
return RPCSuccessResponse(result: ["itemIDs": .array([])], id: id)
}
var newItemIDs: [RPCValue] = []
var newItems: [ShelfItem] = []
for url in panel.urls {
if let bookmark = try? Bookmark(url: url) {
let item = ShelfItem(kind: .file(bookmark: bookmark.data))
newItems.append(item)
newItemIDs.append(.string(item.id.uuidString))
}
}
ShelfStateViewModel.shared.add(newItems)
logDiagnostics("RPC: showFilePicker added \(newItems.count) items for \(bundleIdentifier)")
return RPCSuccessResponse(result: ["itemIDs": .array(newItemIDs)], id: id)
}
private func handleShareShelfItems(params: RPCParams?, id: String) -> Codable {
if let err = checkFileSharingAuthorization(id: id) { return err }
guard let itemIDsValue = params?["itemIDs"],
case .array(let itemIDArray) = itemIDsValue else {
return errorResponse(code: RPCErrorCode.invalidParams, message: "Missing itemIDs array", id: id)
}
let provider = params?["provider"]?.stringValue ?? "AirDrop"
let itemIDs = itemIDArray.compactMap { $0.stringValue }.compactMap { UUID(uuidString: $0) }
let items = ShelfStateViewModel.shared.items.filter { itemIDs.contains($0.id) }
guard !items.isEmpty else {
return errorResponse(code: RPCErrorCode.invalidParams, message: "No matching shelf items found", id: id)
}
let urls = ShelfStateViewModel.shared.resolveFileURLs(for: items)
guard !urls.isEmpty else {
return errorResponse(code: RPCErrorCode.internalError, message: "Could not resolve file URLs", id: id)
}
// Find and invoke the sharing service
QuickShareService.shared.ensureDiscovered()
if let shareProvider = QuickShareService.shared.availableProviders.first(where: { $0.id == provider }) {
Task {
await QuickShareService.shared.shareFilesOrText(urls, using: shareProvider, from: nil)
}
logDiagnostics("RPC: shareShelfItems sharing \(urls.count) files via \(provider) for \(bundleIdentifier)")
return RPCSuccessResponse(result: ["success": .bool(true), "fileCount": .int(urls.count)], id: id)
} else {
return errorResponse(code: RPCErrorCode.invalidParams, message: "Provider '\(provider)' not found", id: id)
}
}
private func handleAddFilesToShelf(params: RPCParams?, id: String) -> Codable {
if let err = checkFileSharingAuthorization(id: id) { return err }
var newItems: [ShelfItem] = []
var newItemIDs: [RPCValue] = []
// Handle file paths
if let pathsValue = params?["paths"],
case .array(let pathArray) = pathsValue {
for pathValue in pathArray {
guard let path = pathValue.stringValue else { continue }
let url = URL(fileURLWithPath: path)
guard FileManager.default.fileExists(atPath: path) else { continue }
if let bookmark = try? Bookmark(url: url) {
let item = ShelfItem(kind: .file(bookmark: bookmark.data))
newItems.append(item)
newItemIDs.append(.string(item.id.uuidString))
}
}
}
// Handle base64-encoded file data
if let filesValue = params?["files"],
case .array(let filesArray) = filesValue {
for fileValue in filesArray {
guard case .object(let fileObj) = fileValue,
let dataStr = fileObj["data"]?.stringValue,
let fileName = fileObj["fileName"]?.stringValue,
let fileData = Data(base64Encoded: dataStr) else { continue }
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent("AtollExtensionFiles", isDirectory: true)
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let fileURL = tempDir.appendingPathComponent(fileName)
guard (try? fileData.write(to: fileURL)) != nil else { continue }
if let bookmark = try? Bookmark(url: fileURL) {
let item = ShelfItem(kind: .file(bookmark: bookmark.data), isTemporary: true)
newItems.append(item)
newItemIDs.append(.string(item.id.uuidString))
}
}
}
// Handle text content
if let text = params?["text"]?.stringValue, !text.isEmpty {
let item = ShelfItem(kind: .text(string: text))
newItems.append(item)
newItemIDs.append(.string(item.id.uuidString))
}
ShelfStateViewModel.shared.add(newItems)
logDiagnostics("RPC: addFilesToShelf added \(newItems.count) items for \(bundleIdentifier)")
return RPCSuccessResponse(result: ["itemIDs": .array(newItemIDs)], id: id)
}
private func handleSubscribeShelfEvents(params: RPCParams?, id: String) -> Codable {
if let err = checkFileSharingAuthorization(id: id) { return err }
server?.registerShelfSubscription(for: bundleIdentifier)
logDiagnostics("RPC: subscribeShelfEvents registered for \(bundleIdentifier)")
return RPCSuccessResponse(result: ["subscribed": .bool(true)], id: id)
}
// MARK: - Client JSON Transformation // MARK: - Client JSON Transformation
/// Converts client wire format to Swift Codable format for enum types. /// Converts client wire format to Swift Codable format for enum types.
@ -452,3 +699,12 @@ final class ExtensionRPCService {
return [typeName: inner] return [typeName: inner]
} }
} }
// MARK: - URL MIME Type Helper
private extension URL {
var mimeType: String? {
guard let uti = UTType(filenameExtension: self.pathExtension) else { return nil }
return uti.preferredMIMEType
}
}