diff --git a/DynamicIsland.xcodeproj/project.pbxproj b/DynamicIsland.xcodeproj/project.pbxproj index 554f95a..021b45f 100644 --- a/DynamicIsland.xcodeproj/project.pbxproj +++ b/DynamicIsland.xcodeproj/project.pbxproj @@ -27,7 +27,6 @@ 7EE1D73D2E6B1E0B00997219 /* ApplicationServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7EE1D73C2E6B1E0B00997219 /* ApplicationServices.framework */; }; 7EE1D73F2E6B1E1200997219 /* IOKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7EE1D73E2E6B1E1200997219 /* IOKit.framework */; }; 7EF19C172F577040005B9929 /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = 7EF19C162F577040005B9929 /* SwiftTerm */; }; - 7EF1D4B92FFFCE3C008D2C32 /* ExtensionEventBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EF1D4B82FFFCE3C008D2C32 /* ExtensionEventBridge.swift */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -54,7 +53,6 @@ 7E9911AF2EFD7D1B002EDFD5 /* Reindeer.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = Reindeer.json; sourceTree = ""; }; 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; }; - 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; }; F54406E72CD4987A00C7AFD9 /* Atoll.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Atoll.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -67,6 +65,13 @@ ); target = F54406E62CD4987A00C7AFD9 /* DynamicIsland */; }; + 7EA69B212F5B100000EB2D16 /* Exceptions for "DynamicIsland" folder in "DynamicIsland" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = F54406E62CD4987A00C7AFD9 /* DynamicIsland */; + }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet section */ @@ -94,6 +99,9 @@ }; F54406E92CD4987A00C7AFD9 /* DynamicIsland */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 7EA69B212F5B100000EB2D16 /* Exceptions for "DynamicIsland" folder in "DynamicIsland" target */, + ); path = DynamicIsland; sourceTree = ""; }; @@ -123,14 +131,6 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 7E07AFF12F181892003A8B1B /* Recovered References */ = { - isa = PBXGroup; - children = ( - 7EF1D4B82FFFCE3C008D2C32 /* ExtensionEventBridge.swift */, - ); - name = "Recovered References"; - sourceTree = ""; - }; 7E81303F2E3FB40500E08F3F /* mediaremote-adapter */ = { isa = PBXGroup; children = ( @@ -173,7 +173,6 @@ 7E81303F2E3FB40500E08F3F /* mediaremote-adapter */, F54406E92CD4987A00C7AFD9 /* DynamicIsland */, F54406E82CD4987A00C7AFD9 /* Products */, - 7E07AFF12F181892003A8B1B /* Recovered References */, ); sourceTree = ""; }; @@ -296,6 +295,7 @@ /* Begin PBXShellScriptBuildPhase section */ 7EFAA8AA2F6F12F3000A99E8 /* ShellScript */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); @@ -318,7 +318,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 7EF1D4B92FFFCE3C008D2C32 /* ExtensionEventBridge.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -451,13 +450,11 @@ AUTOMATION_APPLE_EVENTS = NO; CODE_SIGN_ENTITLEMENTS = DynamicIsland/DynamicIsland.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Developer ID Application"; - CODE_SIGN_STYLE = Manual; + CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 11; DEVELOPMENT_ASSET_PATHS = "\"DynamicIsland/Preview Content\""; - DEVELOPMENT_TEAM = ""; - "DEVELOPMENT_TEAM[sdk=macosx*]" = 9Y64TRM77N; + DEVELOPMENT_TEAM = 9Y64TRM77N; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; diff --git a/DynamicIsland/Assets.xcassets/LocalSend.imageset/Contents.json b/DynamicIsland/Assets.xcassets/LocalSend.imageset/Contents.json new file mode 100644 index 0000000..463ed06 --- /dev/null +++ b/DynamicIsland/Assets.xcassets/LocalSend.imageset/Contents.json @@ -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 + } +} diff --git a/DynamicIsland/Assets.xcassets/LocalSend.imageset/localsend.png b/DynamicIsland/Assets.xcassets/LocalSend.imageset/localsend.png new file mode 100644 index 0000000..5a5f3c1 Binary files /dev/null and b/DynamicIsland/Assets.xcassets/LocalSend.imageset/localsend.png differ diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index 8909c88..c8059cd 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -52,6 +52,7 @@ struct ContentView: View { @ObservedObject var capsLockManager = CapsLockManager.shared @ObservedObject var extensionLiveActivityManager = ExtensionLiveActivityManager.shared @ObservedObject var extensionNotchExperienceManager = ExtensionNotchExperienceManager.shared + @ObservedObject var localSendService = LocalSendService.shared @State private var downloadManager = DownloadManager.shared @Default(.enableStatsFeature) var enableStatsFeature @@ -293,6 +294,19 @@ struct ContentView: View { private var shouldUseHiddenEdgeHoverPolling: Bool { shouldHideUntilHover && !lockScreenManager.isLocked } + + /// Whether the LocalSend live activity should be shown + private var localSendLiveActivityActive: Bool { + localSendService.isSending || + localSendService.transferState == .completed || + isLocalSendFailedOrRejected + } + + private var isLocalSendFailedOrRejected: Bool { + if case .failed = localSendService.transferState { return true } + if case .rejected = localSendService.transferState { return true } + return false + } /// Pill shape for Dynamic Island mode with animated corner radius transitions. private var currentPillShape: DynamicIslandPillShape { @@ -738,6 +752,9 @@ struct ContentView: View { } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .download) && vm.notchState == .closed && downloadManager.isDownloading && Defaults[.enableDownloadListener] && !vm.hideOnClosed { DownloadLiveActivity() .transition(.blurReplace.animation(.interactiveSpring(dampingFraction: 1.2))) + } else if !coordinator.expandingView.show && vm.notchState == .closed && localSendLiveActivityActive && !vm.hideOnClosed { + LocalSendLiveActivity() + .transition(.blurReplace.animation(.interactiveSpring(dampingFraction: 1.2))) } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .doNotDisturb) && vm.notchState == .closed && Defaults[.enableDoNotDisturbDetection] && Defaults[.showDoNotDisturbIndicator] && (doNotDisturbManager.isDoNotDisturbActive || doNotDisturbManager.isFocusToastDismissing) && !vm.hideOnClosed && !lockScreenManager.isLocked { DoNotDisturbLiveActivity() } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .lockScreen) && vm.notchState == .closed && (lockScreenManager.isLocked || !lockScreenManager.isLockIdle) && Defaults[.enableLockScreenLiveActivity] && !vm.hideOnClosed { @@ -1549,7 +1566,6 @@ struct ContentView: View { coordinator.currentView = .shelf openNotch() } else if !isTargeted { - print("DROP EVENT", vm.dropEvent) if vm.dropEvent { vm.dropEvent = false return diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index a6ab283..e8f1a17 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -9243,6 +9243,10 @@ } } }, + "Currently: %@" : { + "comment" : "A label inside the quick share settings view, describing the currently selected quick share service.", + "isCommentAutoGenerated" : true + }, "Cursor" : { "localizations" : { "ko" : { @@ -10028,6 +10032,10 @@ } } }, + "Customize the appearance of the LocalSend device selection popup that appears when you drop files." : { + "comment" : "A description of the LocalSend device picker.", + "isCommentAutoGenerated" : true + }, "Dash outline equals the exact notch bounds." : { "comment" : "A description under the live preview section of the animation editor.", "isCommentAutoGenerated" : true, @@ -10489,6 +10497,10 @@ } } }, + "Device Picker Style" : { + "comment" : "A dropdown menu that lets you choose how the LocalSend device picker is displayed.", + "isCommentAutoGenerated" : true + }, "Devices" : { "comment" : "The title of the \"Devices\" section in the settings menu.", "isCommentAutoGenerated" : true, @@ -13577,6 +13589,10 @@ } } }, + "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" : { "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, @@ -13601,6 +13617,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" : { "localizations" : { "ko" : { @@ -16725,6 +16745,10 @@ } } }, + "Liquid Glass Variant" : { + "comment" : "A label for selecting the variant of the Liquid Glass effect.", + "isCommentAutoGenerated" : true + }, "Listens for Focus session changes via distributed notifications" : { "localizations" : { "ko" : { @@ -16915,6 +16939,10 @@ } } }, + "LocalSend Device Picker" : { + "comment" : "A section header for the LocalSend device picker settings.", + "isCommentAutoGenerated" : true + }, "Lock Screen" : { "localizations" : { "fr" : { @@ -17510,6 +17538,10 @@ } } }, + "Make sure LocalSend is running on other devices" : { + "comment" : "A description of the error.", + "isCommentAutoGenerated" : true + }, "Manage indicator preferences from Settings → Privacy. No manual microphone toggle is required." : { "comment" : "A description of how to manage the microphone privacy indicator preferences.", "isCommentAutoGenerated" : true, @@ -20685,6 +20717,10 @@ } } }, + "No devices found" : { + "comment" : "A message displayed when there are no local send devices available.", + "isCommentAutoGenerated" : true + }, "No events" : { "comment" : "A description displayed when there are no events in the calendar.", "isCommentAutoGenerated" : true, @@ -23220,6 +23256,10 @@ } } }, + "Quick Share settings" : { + "comment" : "A help text for the \"Quick Share settings\" button in the AirDrop view.", + "isCommentAutoGenerated" : true + }, "Quit" : { "localizations" : { "ar" : { @@ -23673,6 +23713,13 @@ } } }, + "Refresh devices" : { + + }, + "Rejected" : { + "comment" : "A label displayed when a device is rejected.", + "isCommentAutoGenerated" : true + }, "Release name" : { "localizations" : { "ar" : { @@ -24831,6 +24878,14 @@ } } }, + "Scan Again" : { + "comment" : "A button to refresh the list of nearby devices.", + "isCommentAutoGenerated" : true + }, + "Scanning for devices..." : { + "comment" : "A description of the scanning process for devices.", + "isCommentAutoGenerated" : true + }, "Screen Assistant" : { "localizations" : { "ko" : { @@ -25252,6 +25307,10 @@ } } }, + "Select Device" : { + "comment" : "A label displayed above the LocalSend device picker view.", + "isCommentAutoGenerated" : true + }, "Select one or more profiles to customize your experience" : { "comment" : "A description below the header that instructs users to select one or more profiles to customize their experience.", "isCommentAutoGenerated" : true, @@ -30844,6 +30903,10 @@ }, "shouldTranslate" : false }, + "Variant %lld" : { + "comment" : "A label for a specific variant of the Liquid Glass.", + "isCommentAutoGenerated" : true + }, "Version" : { "localizations" : { "ar" : { diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 624d355..cfdfc26 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -793,7 +793,8 @@ struct SettingsView: View { SettingsSearchEntry(tab: .shelf, title: "Expanded drag detection area", keywords: ["shelf", "drag"], highlightID: SettingsTab.shelf.highlightID(for: "Expanded drag detection area")), SettingsSearchEntry(tab: .shelf, title: "Copy items on drag", keywords: ["shelf", "drag", "copy"], highlightID: SettingsTab.shelf.highlightID(for: "Copy items on drag")), SettingsSearchEntry(tab: .shelf, title: "Remove from shelf after dragging", keywords: ["shelf", "drag", "remove"], highlightID: SettingsTab.shelf.highlightID(for: "Remove from shelf after dragging")), - SettingsSearchEntry(tab: .shelf, title: "Quick Share Service", keywords: ["shelf", "share", "airdrop"], highlightID: SettingsTab.shelf.highlightID(for: "Quick Share Service")), + SettingsSearchEntry(tab: .shelf, title: "Quick Share Service", keywords: ["shelf", "share", "airdrop", "localsend"], highlightID: SettingsTab.shelf.highlightID(for: "Quick Share Service")), + SettingsSearchEntry(tab: .shelf, title: "LocalSend Device Picker Style", keywords: ["localsend", "glass", "picker", "material"], highlightID: SettingsTab.shelf.highlightID(for: "Device Picker Style")), // Appearance SettingsSearchEntry(tab: .appearance, title: "Main screen style", keywords: ["dynamic island", "pill", "non-notch", "display style", "notch style"], highlightID: SettingsTab.appearance.highlightID(for: "Main screen style")), @@ -3438,12 +3439,14 @@ struct Shelf: View { if let imgData = provider.imageData, let nsImg = NSImage(data: imgData) { Image(nsImage: nsImg) .resizable() - .aspectRatio(contentMode: .fit) + .scaledToFit() + .frame(width: 16, height: 16) + .clipShape(RoundedRectangle(cornerRadius: 3)) } else { Image(systemName: "square.and.arrow.up") + .frame(width: 16, height: 16) } } - .frame(width: 16, height: 16) .foregroundColor(.accentColor) Text(provider.id) } @@ -3459,12 +3462,14 @@ struct Shelf: View { if let imgData = selectedProvider.imageData, let nsImg = NSImage(data: imgData) { Image(nsImage: nsImg) .resizable() - .aspectRatio(contentMode: .fit) + .scaledToFit() + .frame(width: 16, height: 16) + .clipShape(RoundedRectangle(cornerRadius: 3)) } else { Image(systemName: "square.and.arrow.up") + .frame(width: 16, height: 16) } } - .frame(width: 16, height: 16) .foregroundColor(.accentColor) VStack(alignment: .leading, spacing: 2) { Text("Currently selected: \(selectedProvider.id)") @@ -3486,6 +3491,10 @@ struct Shelf: View { .font(.caption) .foregroundStyle(.secondary) } + + if quickShareProvider == "LocalSend" { + LocalSendSettingsSection(highlightID: highlightID) + } } .accentColor(.effectiveAccent) .navigationTitle("Shelf") @@ -3496,6 +3505,41 @@ struct Shelf: View { } } +// MARK: - LocalSend Settings Section + +private struct LocalSendSettingsSection: View { + let highlightID: (String) -> String + + @Default(.localSendDevicePickerGlassMode) private var glassMode + @Default(.localSendDevicePickerLiquidGlassVariant) private var liquidGlassVariant + + var body: some View { + Section { + Picker("Device Picker Style", selection: $glassMode) { + ForEach(LockScreenGlassCustomizationMode.allCases) { mode in + Text(mode.localizedName).tag(mode) + } + } + .pickerStyle(.menu) + + if glassMode == .customLiquid { + Picker("Liquid Glass Variant", selection: $liquidGlassVariant) { + ForEach(LiquidGlassVariant.allCases) { variant in + Text("Variant \(variant.rawValue)").tag(variant) + } + } + .pickerStyle(.menu) + } + } header: { + Text("LocalSend Device Picker") + } footer: { + Text("Customize the appearance of the LocalSend device selection popup that appears when you drop files.") + .font(.caption) + .foregroundStyle(.secondary) + } + } +} + struct LiveActivitiesSettings: View { @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared @ObservedObject var recordingManager = ScreenRecordingManager.shared diff --git a/DynamicIsland/components/Shelf/AirDropView.swift b/DynamicIsland/components/Shelf/AirDropView.swift index cc37037..a65bc3a 100644 --- a/DynamicIsland/components/Shelf/AirDropView.swift +++ b/DynamicIsland/components/Shelf/AirDropView.swift @@ -16,6 +16,8 @@ * along with this program. If not, see . */ +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 isSwitchHover = false var body: some View { dropArea @@ -40,7 +46,103 @@ struct AirDropView: View { .fill(.white.opacity(0.1)) .opacity(0.5) .clipShape(RoundedRectangle(cornerRadius: 10)) - .overlay { dropLabel } + .overlay { + ZStack { + dropLabel + + // Switch button in the top-right for quick-share settings + VStack { + HStack { + Spacer() + Button { + quickShareService.ensureDiscovered() + showShareSettings.toggle() + } label: { + Image(systemName: "switch.2") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 14, height: 14) + .padding(8) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(isSwitchHover ? Color(.windowBackgroundColor).opacity(0.12) : Color.clear) + ) + .foregroundColor(isSwitchHover ? .accentColor : .gray) + .contentShape(Rectangle()) + .onHover { over in isSwitchHover = 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() + .scaledToFit() + .frame(width: 16, height: 16) + .clipShape(RoundedRectangle(cornerRadius: 3)) + } 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() + .scaledToFit() + .frame(width: 20, height: 20) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } else { + Image(systemName: "square.and.arrow.up") + .frame(width: 20, height: 20) + } + } + .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(.trailing, 8) + .padding(.top, 8) + } + Spacer() + } + } + } .aspectRatio(1, contentMode: .fit) .contentShape(Rectangle()) } diff --git a/DynamicIsland/components/Shelf/Services/LocalSendService.swift b/DynamicIsland/components/Shelf/Services/LocalSendService.swift new file mode 100644 index 0000000..e1d5d42 --- /dev/null +++ b/DynamicIsland/components/Shelf/Services/LocalSendService.swift @@ -0,0 +1,1117 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +import Foundation +import Network +import Defaults +import UniformTypeIdentifiers +import Darwin + +struct LocalSendDeviceInfo: Identifiable, Hashable, Sendable { + let id: String + let alias: String + let ip: String + let port: Int + let https: Bool + let model: String? + + var displayName: String { + if let model, !model.isEmpty { + return "\(alias) (\(model))" + } + return alias + } + + var baseURL: String { + let scheme = https ? "https" : "http" + return "\(scheme)://\(ip):\(port)" + } +} + +enum LocalSendTransferState: Equatable { + case idle + case sending + case completed + case failed(String) + case rejected(deviceID: String) +} + +@MainActor +final class LocalSendService: NSObject, ObservableObject { + static let shared = LocalSendService() + + @Published private(set) var devices: [LocalSendDeviceInfo] = [] + @Published private(set) var isRefreshing = false + @Published private(set) var isSending = false + @Published private(set) var sendProgress: Double = 0 + @Published private(set) var transferState: LocalSendTransferState = .idle + @Published private(set) var rejectedDeviceIDs: Set = [] + @Published var selectedDeviceID: String { + didSet { Defaults[.localSendSelectedDeviceID] = selectedDeviceID } + } + + private let multicastGroupHost = "224.0.0.167" + private let defaultPort = 53317 + private var connectionGroup: NWConnectionGroup? + private var registerListener: NWListener? + private var cleanupTask: Task? + private var announceTask: Task? + private var activeRefreshTask: Task? + private var refreshSessionID = UUID() + private var discoveredByID: [String: (device: LocalSendDeviceInfo, lastSeen: Date)] = [:] + private var recentProbeIPs: [String] = [] + private var knownPeerIPs: [String] = [] + private var isStarted = false + private var completionDismissTask: Task? + + private override init() { + selectedDeviceID = Defaults[.localSendSelectedDeviceID] + super.init() + } + + func clearRejectedStatus(for deviceID: String) { + rejectedDeviceIDs.remove(deviceID) + } + + func clearAllRejectedStatuses() { + rejectedDeviceIDs.removeAll() + } + + func startDiscovery() { + guard !isStarted else { return } + isStarted = true + + startRegisterListenerIfNeeded() + + do { + let group = try NWMulticastGroup(for: [ + .hostPort( + host: .init(multicastGroupHost), + port: .init(integerLiteral: NWEndpoint.Port.IntegerLiteralType(defaultPort)) + ), + ]) + let params = NWParameters.udp + params.allowLocalEndpointReuse = true + params.includePeerToPeer = true + + let connectionGroup = NWConnectionGroup(with: group, using: params) + connectionGroup.setReceiveHandler(maximumMessageSize: 65_536) { [weak self] message, content, _ in + guard let content, let self else { return } + Task { @MainActor in + self.handleIncoming(content: content, endpoint: message.remoteEndpoint) + } + } + + connectionGroup.stateUpdateHandler = { _ in } + connectionGroup.start(queue: .global(qos: .utility)) + self.connectionGroup = connectionGroup + + sendAnnouncement() + + cleanupTask = Task { [weak self] in + while let self, !Task.isCancelled { + try? await Task.sleep(nanoseconds: 15_000_000_000) + self.cleanupStale() + } + } + + announceTask = Task { [weak self] in + while let self, !Task.isCancelled { + try? await Task.sleep(nanoseconds: 8_000_000_000) + self.sendAnnouncement() + } + } + } catch { + Logger.log("LocalSend discovery start failed: \(error.localizedDescription)", category: .extensions) + } + } + + func refreshDeviceScan() { + activeRefreshTask?.cancel() + startDiscovery() + + let sessionID = UUID() + refreshSessionID = sessionID + isRefreshing = true + + // Keep listener state and run a LocalSend-like refresh sequence: + // multicast announce burst first, then targeted/fallback HTTP discovery. + activeRefreshTask = Task { [weak self] in + guard let self, !Task.isCancelled else { return } + + let startedAt = Date() + defer { + if self.refreshSessionID == sessionID { + self.isRefreshing = false + self.activeRefreshTask = nil + } + } + + let burstDelays: [UInt64] = [100_000_000, 500_000_000, 2_000_000_000] + for delay in burstDelays { + try? await Task.sleep(nanoseconds: delay) + guard !Task.isCancelled else { return } + self.sendAnnouncement() + } + + guard !Task.isCancelled else { return } + await self.probeNearbyDevicesDirectly(limit: 20, timeout: 0.14) + + guard !Task.isCancelled else { return } + if !self.hasFreshDiscovery(since: startedAt), + let localIP = self.ownIPv4Address() { + await self.probeLocalSubnetLegacy(localIP: localIP, timeout: 0.12) + } + + guard !Task.isCancelled else { return } + self.pruneUnavailableDevices(since: startedAt) + } + } + + private func pruneUnavailableDevices(since date: Date) { + let previousCount = discoveredByID.count + discoveredByID = discoveredByID.filter { $0.value.lastSeen >= date } + if discoveredByID.count != previousCount { + refreshDevices() + } + } + + private func probeNearbyDevicesDirectly(limit: Int = 12, timeout: TimeInterval = 0.14) async { + let port = defaultPort + let candidates = candidateIPsForActiveProbe(limit: max(1, limit)) + guard !candidates.isEmpty else { return } + let deadline = Date().addingTimeInterval(1.0) + + await withTaskGroup(of: LocalSendDeviceInfo?.self) { group in + let maxConcurrent = 4 + var iterator = candidates.makeIterator() + + for _ in 0 ..< min(maxConcurrent, candidates.count) { + guard let ip = iterator.next() else { break } + group.addTask { + await Self.probeDeviceInfo(at: ip, port: port, timeout: 0.14) + } + } + + while let result = await group.next() { + if Date() > deadline { + group.cancelAll() + return + } + + guard !Task.isCancelled else { + group.cancelAll() + return + } + + if let device = result { + discoveredByID[device.id] = (device, Date()) + rememberKnownPeerIP(device.ip) + rememberRecentProbeIP(device.ip) + } + + if let ip = iterator.next() { + group.addTask { + await Self.probeDeviceInfo(at: ip, port: port, timeout: timeout) + } + } + } + } + + refreshDevices() + } + + private func candidateIPsForActiveProbe(limit: Int) -> [String] { + var ordered: [String] = [] + var seen = Set() + + if let selected = devices.first(where: { $0.id == selectedDeviceID })?.ip, + seen.insert(selected).inserted { + ordered.append(selected) + } + + for ip in recentProbeIPs where seen.insert(ip).inserted { + ordered.append(ip) + } + + for ip in discoveredByID.values.map(\.device.ip) where seen.insert(ip).inserted { + ordered.append(ip) + } + + for ip in knownPeerIPs where seen.insert(ip).inserted { + ordered.append(ip) + } + + if ordered.count > limit { + return Array(ordered.prefix(limit)) + } + return ordered + } + + private func probeLocalSubnetLegacy(localIP: String, timeout: TimeInterval) async { + guard isValidIPv4(localIP) else { return } + let parts = localIP.split(separator: ".") + guard parts.count == 4 else { return } + + let prefix = parts.prefix(3).joined(separator: ".") + let host = Int(parts[3]) ?? 0 + let candidates = (1 ... 254) + .filter { $0 != host } + .map { "\(prefix).\($0)" } + + await probeExactIPs(candidates, timeout: timeout, concurrency: 50) + } + + private func probeExactIPs(_ ips: [String], timeout: TimeInterval, concurrency: Int) async { + let port = defaultPort + let unique = Array(NSOrderedSet(array: ips).compactMap { $0 as? String }) + guard !unique.isEmpty else { return } + + await withTaskGroup(of: LocalSendDeviceInfo?.self) { group in + var iterator = unique.makeIterator() + + for _ in 0 ..< min(max(1, concurrency), unique.count) { + guard let ip = iterator.next() else { break } + group.addTask { + await Self.probeDeviceInfo(at: ip, port: port, timeout: timeout) + } + } + + while let result = await group.next() { + guard !Task.isCancelled else { + group.cancelAll() + return + } + + if let device = result { + discoveredByID[device.id] = (device, Date()) + rememberKnownPeerIP(device.ip) + rememberRecentProbeIP(device.ip) + } + + if let ip = iterator.next() { + group.addTask { + await Self.probeDeviceInfo(at: ip, port: port, timeout: timeout) + } + } + } + } + + refreshDevices() + } + + private func hasFreshDiscovery(since date: Date) -> Bool { + discoveredByID.values.contains { $0.lastSeen >= date } + } + + private nonisolated static func probeDeviceInfo(at ip: String, port: Int, timeout: TimeInterval) async -> LocalSendDeviceInfo? { + let schemes = ["http", "https"] + let paths = ["/api/localsend/v2/info"] + + for scheme in schemes { + for path in paths { + guard var components = URLComponents(string: "\(scheme)://\(ip):\(port)\(path)") else { continue } + components.queryItems = [ + URLQueryItem(name: "fingerprint", value: "atoll.localsend.bridge"), + ] + guard let url = components.url else { continue } + + var request = URLRequest(url: url) + request.timeoutInterval = timeout + request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData + + do { + let (data, response) = try await probeSession.data(for: request) + guard let http = response as? HTTPURLResponse, + (200 ... 299).contains(http.statusCode), + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let fingerprint = json["fingerprint"] as? String, + let alias = json["alias"] as? String, + fingerprint != "atoll.localsend.bridge" + else { + continue + } + + let model = json["deviceModel"] as? String + let usesHTTPS = (json["protocol"] as? String) == "https" || scheme == "https" + + return LocalSendDeviceInfo( + id: fingerprint, + alias: alias, + ip: ip, + port: port, + https: usesHTTPS, + model: model + ) + } catch { + continue + } + } + } + + return nil + } + + private nonisolated static let probeSession: URLSession = { + let config = URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest = 0.2 + config.timeoutIntervalForResource = 0.2 + config.waitsForConnectivity = false + return URLSession(configuration: config, delegate: LocalSendTLSDelegate(), delegateQueue: nil) + }() + + private func rememberRecentProbeIP(_ ip: String) { + guard isValidIPv4(ip) else { return } + recentProbeIPs.removeAll { $0 == ip } + recentProbeIPs.insert(ip, at: 0) + if recentProbeIPs.count > 12 { + recentProbeIPs = Array(recentProbeIPs.prefix(12)) + } + } + + private func rememberKnownPeerIP(_ ip: String) { + guard isValidIPv4(ip) else { return } + knownPeerIPs.removeAll { $0 == ip } + knownPeerIPs.insert(ip, at: 0) + if knownPeerIPs.count > 48 { + knownPeerIPs = Array(knownPeerIPs.prefix(48)) + } + } + + private func arpNeighborIPs() -> [String] { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/sbin/arp") + process.arguments = ["-an"] + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = Pipe() + + do { + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { return [] } + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + guard let output = String(data: data, encoding: .utf8) else { return [] } + + let regex = try NSRegularExpression(pattern: #"\((\d{1,3}(?:\.\d{1,3}){3})\)"#) + let nsrange = NSRange(output.startIndex..() + for match in matches { + guard match.numberOfRanges > 1, + let range = Range(match.range(at: 1), in: output) + else { continue } + let ip = String(output[range]) + guard isValidIPv4(ip) else { continue } + if ip.hasPrefix("169.254.") { continue } + if ip == "255.255.255.255" { continue } + + if let ownPrefix { + let ipPrefix = ip.split(separator: ".").prefix(3).joined(separator: ".") + guard ipPrefix == ownPrefix else { continue } + } + + if seen.insert(ip).inserted { + ips.append(ip) + } + } + + return ips + } catch { + return [] + } + } + + private func localSubnetSweepIPs(radius: Int) -> [String] { + guard let own = ownIPv4Address(), isValidIPv4(own) else { return [] } + let parts = own.split(separator: ".").compactMap { Int($0) } + guard parts.count == 4 else { return [] } + + let prefix = "\(parts[0]).\(parts[1]).\(parts[2])" + let myHost = parts[3] + let start = max(1, myHost - radius) + let end = min(254, myHost + radius) + + return (start ... end) + .filter { $0 != myHost } + .map { "\(prefix).\($0)" } + } + + private func ownIPv4Address() -> String? { + var ifaddr: UnsafeMutablePointer? + guard getifaddrs(&ifaddr) == 0, let first = ifaddr else { return nil } + defer { freeifaddrs(ifaddr) } + + var ptr: UnsafeMutablePointer? = first + while let current = ptr { + defer { ptr = current.pointee.ifa_next } + + let family = current.pointee.ifa_addr.pointee.sa_family + let flags = Int32(current.pointee.ifa_flags) + guard family == UInt8(AF_INET), + (flags & IFF_UP) == IFF_UP, + (flags & IFF_LOOPBACK) == 0 + else { continue } + + var hostBuffer = [CChar](repeating: 0, count: Int(NI_MAXHOST)) + var addr = current.pointee.ifa_addr.pointee + let result = getnameinfo( + &addr, + socklen_t(current.pointee.ifa_addr.pointee.sa_len), + &hostBuffer, + socklen_t(hostBuffer.count), + nil, + 0, + NI_NUMERICHOST + ) + guard result == 0 else { continue } + + let ip = String(cString: hostBuffer) + if isValidIPv4(ip) { + return ip + } + } + + return nil + } + + private func isValidIPv4(_ ip: String) -> Bool { + let parts = ip.split(separator: ".") + guard parts.count == 4 else { return false } + for p in parts { + guard let v = Int(p), (0 ... 255).contains(v) else { return false } + } + return true + } + + func send(items: [Any]) async throws { + let startedAt = Date() + isSending = true + sendProgress = 0 + transferState = .sending + completionDismissTask?.cancel() + + do { + guard let target = devices.first(where: { $0.id == selectedDeviceID }) ?? devices.first else { + throw LocalSendServiceError.noDeviceSelected + } + + let files = try await buildTransferFiles(from: items) + guard !files.isEmpty else { throw LocalSendServiceError.noTransferableItems } + + let prepare = try await prepareUpload(files: files, to: target) + guard !prepare.fileTokens.isEmpty else { + sendProgress = 1 + await finishSending(startedAt: startedAt, success: true) + return + } + + let uploads: [(TransferFile, String)] = files.compactMap { file in + guard let token = prepare.fileTokens[file.id] else { return nil } + return (file, token) + } + + // Compute total bytes to provide smooth overall progress + let totalBytes = uploads.reduce(Int64(0)) { acc, entry in acc + Int64(entry.0.data.count) } + var bytesCompleted: Int64 = 0 + + for (index, entry) in uploads.enumerated() { + let file = entry.0 + let fileSize = Int64(file.data.count) + + try await upload(file: file, sessionID: prepare.sessionID, token: entry.1, to: target) { fileFraction in + if totalBytes > 0 { + let currentSent = Int64(Double(fileSize) * fileFraction) + let overall = Double(bytesCompleted + currentSent) / Double(totalBytes) + Task { @MainActor in self.sendProgress = overall } + } else { + // Fallback to file-index-based progress when sizes unknown + Task { @MainActor in self.sendProgress = Double(index) / Double(max(uploads.count, 1)) } + } + } + + bytesCompleted += fileSize + } + + sendProgress = 1 + await finishSending(startedAt: startedAt, success: true) + } catch let error as LocalSendServiceError { + if case .transferRejected = error { + rejectedDeviceIDs.insert(selectedDeviceID) + transferState = .rejected(deviceID: selectedDeviceID) + } else { + transferState = .failed(error.localizedDescription) + } + await finishSending(startedAt: startedAt, success: false) + throw error + } catch { + transferState = .failed(error.localizedDescription) + await finishSending(startedAt: startedAt, success: false) + throw error + } + } + + private func finishSending(startedAt: Date, success: Bool) async { + let elapsed = Date().timeIntervalSince(startedAt) + let minimumVisibleDuration: TimeInterval = 0.8 + if elapsed < minimumVisibleDuration { + let remaining = minimumVisibleDuration - elapsed + try? await Task.sleep(nanoseconds: UInt64(remaining * 1_000_000_000)) + } + isSending = false + sendProgress = 0 + + if success { + transferState = .completed + // Auto-dismiss completed state after 3 seconds + completionDismissTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: 3_000_000_000) + if case .completed = self.transferState { + self.transferState = .idle + } + } + } else { + // Auto-dismiss failed/rejected state after 4 seconds + completionDismissTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: 4_000_000_000) + if case .failed = self.transferState { + self.transferState = .idle + } else if case .rejected = self.transferState { + self.transferState = .idle + } + } + } + } + + private func sendAnnouncement() { + let payload: [String: Any] = [ + "alias": Host.current().localizedName ?? "Atoll", + "version": "2.1", + "deviceModel": "Mac", + "deviceType": "desktop", + "fingerprint": "atoll.localsend.bridge", + "port": defaultPort, + // Use HTTP here so LocalSend peers can quickly fail over to UDP response + // when Atoll is not serving LocalSend register endpoint. + "protocol": "http", + "download": false, + "announcement": true, + "announce": true, + ] + guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return } + connectionGroup?.send(content: data, completion: { _ in }) + } + + private func startRegisterListenerIfNeeded() { + guard registerListener == nil else { return } + + do { + let params = NWParameters.tcp + params.allowLocalEndpointReuse = true + params.includePeerToPeer = true + + let listener = try NWListener( + using: params, + on: NWEndpoint.Port(integerLiteral: NWEndpoint.Port.IntegerLiteralType(defaultPort)) + ) + + listener.stateUpdateHandler = { state in + if case let .failed(error) = state { + Task { @MainActor [weak self] in + Logger.log("LocalSend register listener failed: \(error.localizedDescription)", category: .extensions) + self?.registerListener = nil + } + } + } + + listener.newConnectionHandler = { [weak self] connection in + Task { @MainActor in + self?.handleRegisterConnection(connection) + } + } + + listener.start(queue: .global(qos: .utility)) + registerListener = listener + } catch { + Logger.log("LocalSend register listener start failed: \(error.localizedDescription)", category: .extensions) + } + } + + private func handleRegisterConnection(_ connection: NWConnection) { + connection.start(queue: .global(qos: .utility)) + connection.receive(minimumIncompleteLength: 1, maximumLength: 131_072) { [weak self] content, _, _, _ in + guard let self, let data = content, !data.isEmpty else { + connection.cancel() + return + } + + Task { @MainActor in + let response = self.processRegisterRequest(data: data, from: connection.endpoint) + connection.send(content: response, completion: .contentProcessed { _ in + connection.cancel() + }) + } + } + } + + private func processRegisterRequest(data: Data, from endpoint: NWEndpoint) -> Data { + guard let request = String(data: data, encoding: .utf8) else { + return httpResponse(status: 400, json: ["error": "invalid-request"]) + } + + let parts = request.components(separatedBy: "\r\n\r\n") + guard let head = parts.first, + let firstLine = head.components(separatedBy: "\r\n").first + else { + return httpResponse(status: 400, json: ["error": "invalid-request"]) + } + + let tokens = firstLine.split(separator: " ") + guard tokens.count >= 2 else { + return httpResponse(status: 400, json: ["error": "invalid-request"]) + } + + let method = String(tokens[0]).uppercased() + let rawPath = String(tokens[1]) + let path = rawPath.components(separatedBy: "?").first ?? rawPath + + if method == "POST", path == "/api/localsend/v2/register" || path == "/api/localsend/v3/register" { + let callerIP = endpointIPv4(endpoint) + let bodyText = parts.dropFirst().joined(separator: "\r\n\r\n") + let bodyData = bodyText.data(using: .utf8) + let json = bodyData.flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] } + + if let json, + let fingerprint = json["fingerprint"] as? String, + let alias = json["alias"] as? String, + let callerIP, + fingerprint != "atoll.localsend.bridge" { + let device = LocalSendDeviceInfo( + id: fingerprint, + alias: alias, + ip: callerIP, + port: (json["port"] as? Int) ?? defaultPort, + https: (json["protocol"] as? String) == "https", + model: json["deviceModel"] as? String + ) + discoveredByID[fingerprint] = (device, Date()) + rememberKnownPeerIP(callerIP) + rememberRecentProbeIP(callerIP) + refreshDevices() + } else if let callerIP { + // Be tolerant to partial HTTP payloads: still ACK register and probe caller directly. + Task { @MainActor [weak self] in + guard let self else { return } + if let device = await Self.probeDeviceInfo(at: callerIP, port: defaultPort, timeout: 0.35) { + self.discoveredByID[device.id] = (device, Date()) + self.rememberKnownPeerIP(device.ip) + self.rememberRecentProbeIP(device.ip) + self.refreshDevices() + } + } + } + + let responseJSON: [String: Any] = [ + "alias": Host.current().localizedName ?? "Atoll", + "version": "2.1", + "deviceModel": "Mac", + "deviceType": "desktop", + "token": "atoll.localsend.bridge", + "fingerprint": "atoll.localsend.bridge", + "download": false, + "hasWebInterface": false, + ] + return httpResponse(status: 200, json: responseJSON) + } + + if method == "GET", path == "/api/localsend/v2/info" { + let responseJSON: [String: Any] = [ + "alias": Host.current().localizedName ?? "Atoll", + "version": "2.1", + "deviceModel": "Mac", + "deviceType": "desktop", + "fingerprint": "atoll.localsend.bridge", + "port": defaultPort, + "protocol": "http", + "download": false, + ] + return httpResponse(status: 200, json: responseJSON) + } + + return httpResponse(status: 404, json: ["error": "not-found"]) + } + + private func endpointIPv4(_ endpoint: NWEndpoint) -> String? { + guard case let .hostPort(host, _) = endpoint else { return nil } + let ip = host.debugDescription.replacingOccurrences(of: "\"", with: "") + return isValidIPv4(ip) ? ip : nil + } + + private func httpResponse(status: Int, json: [String: Any]) -> Data { + let body = (try? JSONSerialization.data(withJSONObject: json)) ?? Data("{}".utf8) + let headers = [ + "HTTP/1.1 \(status) \(status == 200 ? "OK" : "Error")", + "Content-Type: application/json", + "Content-Length: \(body.count)", + "Connection: close", + "", + "", + ].joined(separator: "\r\n") + + var response = Data(headers.utf8) + response.append(body) + return response + } + + private func handleIncoming(content: Data, endpoint: NWEndpoint?) { + guard let json = try? JSONSerialization.jsonObject(with: content) as? [String: Any], + let fingerprint = json["fingerprint"] as? String, + let alias = json["alias"] as? String + else { return } + + if fingerprint == "atoll.localsend.bridge" { return } + + let ip: String + if let announced = json["ip"] as? String { + ip = announced + } else if case let .hostPort(host, _) = endpoint { + ip = host.debugDescription.replacingOccurrences(of: "\"", with: "") + } else { + return + } + + let port = (json["port"] as? Int) ?? defaultPort + let https = (json["protocol"] as? String) == "https" + let model = json["deviceModel"] as? String + + let device = LocalSendDeviceInfo( + id: fingerprint, + alias: alias, + ip: ip, + port: port, + https: https, + model: model + ) + + discoveredByID[fingerprint] = (device, Date()) + rememberKnownPeerIP(ip) + rememberRecentProbeIP(ip) + refreshDevices() + } + + private func cleanupStale() { + let cutoff = Date().addingTimeInterval(-30) + discoveredByID = discoveredByID.filter { $0.value.lastSeen > cutoff } + refreshDevices() + } + + private func refreshDevices() { + devices = discoveredByID.values.map(\.device).sorted { $0.alias.localizedCaseInsensitiveCompare($1.alias) == .orderedAscending } + if !devices.contains(where: { $0.id == selectedDeviceID }), let first = devices.first { + selectedDeviceID = first.id + } + } + + private struct TransferFile { + let id: String + let name: String + let mimeType: String + let data: Data + } + + private func buildTransferFiles(from items: [Any]) async throws -> [TransferFile] { + var result: [TransferFile] = [] + for item in items { + if let url = item as? URL, url.isFileURL { + if let data = try? Data(contentsOf: url) { + result.append(TransferFile( + id: UUID().uuidString, + name: url.lastPathComponent, + mimeType: preferredTransferMimeType(for: url), + data: data + )) + } + } else if let url = item as? URL { + let string = url.absoluteString + result.append(TransferFile( + id: UUID().uuidString, + name: "link.url", + mimeType: "text/uri-list", + data: Data(string.utf8) + )) + } else if let text = item as? String { + result.append(TransferFile( + id: UUID().uuidString, + name: "text.txt", + mimeType: "text/plain", + data: Data(text.utf8) + )) + } + } + return result + } + + private func preferredTransferMimeType(for url: URL) -> String { + let ext = url.pathExtension.lowercased() + if ext == "svg" { + // Some LocalSend receivers try gallery/image-specific write paths for SVG and fail. + return "application/octet-stream" + } + return url.mimeType ?? "application/octet-stream" + } + + private func prepareUpload(files: [TransferFile], to device: LocalSendDeviceInfo) async throws -> (sessionID: String, fileTokens: [String: String]) { + var filesMap: [String: Any] = [:] + for file in files { + filesMap[file.id] = [ + "id": file.id, + "fileName": file.name, + "size": file.data.count, + "fileType": file.mimeType, + ] + } + + let payload: [String: Any] = [ + "info": [ + "alias": Host.current().localizedName ?? "Atoll", + "version": "2.1", + "deviceModel": "Mac", + "deviceType": "desktop", + "fingerprint": "atoll.localsend.bridge", + "token": "atoll.localsend.bridge", + "port": defaultPort, + "protocol": device.https ? "https" : "http", + "download": false, + ], + "files": filesMap, + ] + + var lastError: Error? + for baseURL in candidateBaseURLs(for: device) { + do { + return try await prepareUpload(payload: payload, baseURL: baseURL) + } catch { + lastError = error + Logger.log("LocalSend prepare-upload failed via \(baseURL): \(error.localizedDescription)", category: .extensions) + if !shouldRetryAcrossSchemes(error) { + throw error + } + } + } + + throw lastError ?? LocalSendServiceError.invalidResponse + } + + private func prepareUpload(payload: [String: Any], baseURL: String) async throws -> (sessionID: String, fileTokens: [String: String]) { + guard let url = URL(string: "\(baseURL)/api/localsend/v2/prepare-upload") else { + throw LocalSendServiceError.invalidTarget + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONSerialization.data(withJSONObject: payload) + + let (data, response) = try await trustedSession.data(for: request) + guard let http = response as? HTTPURLResponse else { throw LocalSendServiceError.invalidResponse } + + if http.statusCode == 204 { + return ("", [:]) + } + // HTTP 403 means the transfer was rejected by the recipient + if http.statusCode == 403 { + throw LocalSendServiceError.transferRejected + } + guard (200 ... 299).contains(http.statusCode) else { + let body = String(data: data, encoding: .utf8) + throw LocalSendServiceError.server(status: http.statusCode, body: body) + } + + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let sessionID = json["sessionId"] as? String, + let rawFileTokens = json["files"] as? [String: Any] + else { throw LocalSendServiceError.invalidResponse } + + var fileTokens: [String: String] = [:] + for (k, v) in rawFileTokens { + if let s = v as? String { + fileTokens[k] = s + } + } + + return (sessionID, fileTokens) + } + + private func upload(file: TransferFile, sessionID: String, token: String, to device: LocalSendDeviceInfo, progress: @escaping (Double) -> Void) async throws { + var lastError: Error? + for baseURL in candidateBaseURLs(for: device) { + do { + try await upload(file: file, sessionID: sessionID, token: token, baseURL: baseURL, progress: progress) + return + } catch { + lastError = error + Logger.log("LocalSend upload failed via \(baseURL) for \(file.name): \(error.localizedDescription)", category: .extensions) + if !shouldRetryAcrossSchemes(error) { + throw error + } + } + } + throw lastError ?? LocalSendServiceError.invalidResponse + } + + private func upload(file: TransferFile, sessionID: String, token: String, baseURL: String, progress: @escaping (Double) -> Void) async throws { + var components = URLComponents(string: "\(baseURL)/api/localsend/v2/upload") + components?.queryItems = [ + URLQueryItem(name: "sessionId", value: sessionID), + URLQueryItem(name: "fileId", value: file.id), + URLQueryItem(name: "token", value: token), + ] + + guard let url = components?.url else { throw LocalSendServiceError.invalidTarget } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue(file.mimeType, forHTTPHeaderField: "Content-Type") + request.setValue("\(file.data.count)", forHTTPHeaderField: "Content-Length") + + // Use uploadTask to receive per-byte progress via delegate + let delegate = UploadProgressDelegate { sent, expected in + guard expected > 0 else { return } + let fraction = Double(sent) / Double(expected) + progress(fraction) + } + + let session = URLSession(configuration: .default, delegate: delegate, delegateQueue: nil) + + let (data, response) = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<(Data, URLResponse), Error>) in + let task = session.uploadTask(with: request, from: file.data) { data, response, error in + if let error = error { + continuation.resume(throwing: error) + return + } + guard let data = data, let response = response else { + continuation.resume(throwing: LocalSendServiceError.invalidResponse) + return + } + continuation.resume(returning: (data, response)) + } + task.resume() + } + + session.finishTasksAndInvalidate() + + guard let http = response as? HTTPURLResponse, (200 ... 299).contains(http.statusCode) else { + let status = (response as? HTTPURLResponse)?.statusCode ?? -1 + let body = String(data: data, encoding: .utf8) + throw LocalSendServiceError.server(status: status, body: body) + } + } + + private func candidateBaseURLs(for device: LocalSendDeviceInfo) -> [String] { + let primary = device.baseURL + let alternateScheme = device.https ? "http" : "https" + let alternate = "\(alternateScheme)://\(device.ip):\(device.port)" + return primary == alternate ? [primary] : [primary, alternate] + } + + private func shouldRetryAcrossSchemes(_ error: Error) -> Bool { + if case LocalSendServiceError.server = error { + // A peer responded with a concrete HTTP error; fallback to another scheme is usually noise. + return false + } + + let nsError = error as NSError + if nsError.domain == NSURLErrorDomain { + let code = URLError.Code(rawValue: nsError.code) + switch code { + case .timedOut, .networkConnectionLost, .notConnectedToInternet, + .cannotFindHost, .cannotConnectToHost, .secureConnectionFailed, + .dnsLookupFailed: + return true + default: + return false + } + } + + return false + } + + private lazy var trustedSession: URLSession = { + URLSession(configuration: .default, delegate: LocalSendTLSDelegate(), delegateQueue: nil) + }() +} + +private class LocalSendTLSDelegate: NSObject, URLSessionDelegate { + func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + if let trust = challenge.protectionSpace.serverTrust { + return (.useCredential, URLCredential(trust: trust)) + } + return (.performDefaultHandling, nil) + } +} + +private final class UploadProgressDelegate: LocalSendTLSDelegate, URLSessionTaskDelegate { + private let onProgress: (Int64, Int64) -> Void + + init(onProgress: @escaping (Int64, Int64) -> Void) { + self.onProgress = onProgress + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didSendBodyData bytesSent: Int64, totalBytesSent: Int64, totalBytesExpectedToSend: Int64) { + Task { @MainActor in + onProgress(totalBytesSent, totalBytesExpectedToSend) + } + } +} + +private extension URL { + var mimeType: String? { + guard !pathExtension.isEmpty, + let type = UTType(filenameExtension: pathExtension) + else { return nil } + return type.preferredMIMEType + } +} + +enum LocalSendServiceError: LocalizedError { + case noDeviceSelected + case noTransferableItems + case invalidTarget + case invalidResponse + case server(status: Int, body: String?) + case transferRejected + + var errorDescription: String? { + switch self { + case .noDeviceSelected: + return "No LocalSend device selected" + case .noTransferableItems: + return "No transferable files or text found" + case .invalidTarget: + return "Invalid LocalSend target" + case .invalidResponse: + return "Invalid response from LocalSend peer" + case .server(let status, let body): + if let body, !body.isEmpty { + return "LocalSend peer error (\(status)): \(body)" + } + return "LocalSend peer error (\(status))" + case .transferRejected: + return "Transfer was rejected by the recipient" + } + } +} \ No newline at end of file diff --git a/DynamicIsland/components/Shelf/Services/QuickShareService.swift b/DynamicIsland/components/Shelf/Services/QuickShareService.swift index 548402f..b77f925 100644 --- a/DynamicIsland/components/Shelf/Services/QuickShareService.swift +++ b/DynamicIsland/components/Shelf/Services/QuickShareService.swift @@ -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() diff --git a/DynamicIsland/components/Shelf/ViewModels/ShelfStateViewModel.swift b/DynamicIsland/components/Shelf/ViewModels/ShelfStateViewModel.swift index ab685ff..e1972f8 100644 --- a/DynamicIsland/components/Shelf/ViewModels/ShelfStateViewModel.swift +++ b/DynamicIsland/components/Shelf/ViewModels/ShelfStateViewModel.swift @@ -49,19 +49,25 @@ final class ShelfStateViewModel: ObservableObject { var merged = items // Deduplicate by identityKey while preserving order (existing first) var seen: Set = 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) { diff --git a/DynamicIsland/components/Shelf/Views/FileShareView.swift b/DynamicIsland/components/Shelf/Views/FileShareView.swift index 9c0075e..13e2316 100644 --- a/DynamicIsland/components/Shelf/Views/FileShareView.swift +++ b/DynamicIsland/components/Shelf/Views/FileShareView.swift @@ -28,29 +28,49 @@ 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 isSwitchHover = false + @State private var autoCloseToken = UUID() @State private var hostView: NSView? @State private var interactionNonce: UUID = .init() @State private var isProcessing = false + @State private var pendingDropProviders: [NSItemProvider]? + @State private var showLocalSendPicker = false private var selectedProvider: QuickShareProvider { 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 - Task { await handleDrop(providers) } + if selectedProvider.id == "LocalSend" { + pendingDropProviders = providers + showLocalSendPicker = true + } else { + Task { await handleDrop(providers) } + } 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 +105,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 +128,116 @@ struct FileShareView: View { .font(.system(.headline, design: .rounded)) .foregroundColor(.white.opacity(0.8)) .multilineTextAlignment(.center) + .lineLimit(1) + .truncationMode(.tail) .frame(maxWidth: .infinity) } .padding(18) .frame(maxWidth: .infinity) + + // Switch button pinned to top-right corner + VStack { + HStack { + Spacer() + Button { + vm.setAutoCloseSuppression(true, token: autoCloseToken) + quickShare.ensureDiscovered() + showQuickSharePopover.toggle() + } label: { + Image(systemName: "switch.2") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 14, height: 14) + .padding(8) + .background(RoundedRectangle(cornerRadius: 8).fill(isSwitchHover ? Color(.windowBackgroundColor).opacity(0.12) : Color.clear)) + .foregroundColor(isSwitchHover ? .accentColor : .gray) + } + .buttonStyle(PlainButtonStyle()) + .onHover { hovering in + isSwitchHover = 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() + .scaledToFit() + .frame(width: 16, height: 16) + .clipShape(RoundedRectangle(cornerRadius: 3)) + } 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() + .scaledToFit() + .frame(width: 20, height: 20) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } else { + Image(systemName: "square.and.arrow.up") + .frame(width: 20, height: 20) + } + } + .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(.trailing, 8) + .padding(.top, 8) + } + Spacer() + } // Loading overlay if isProcessing || quickShare.isPickerOpen { @@ -120,8 +249,63 @@ 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)) + .onChange(of: showLocalSendPicker) { _, show in + if show { + LocalSendDevicePickerWindowManager.shared.show( + onDeviceSelected: { device in + localSend.selectedDeviceID = device.id + if let providers = pendingDropProviders { + // Close the picker first since handleDrop will use quickShare properly + showLocalSendPicker = false + Task { + await handleDrop(providers) + pendingDropProviders = nil + } + } + }, + onDismiss: { + showLocalSendPicker = false + pendingDropProviders = nil + } + ) + } else { + LocalSendDevicePickerWindowManager.shared.hide() + } + } } // MARK: - Actions diff --git a/DynamicIsland/components/Shelf/Views/LocalSendLiveActivity.swift b/DynamicIsland/components/Shelf/Views/LocalSendLiveActivity.swift new file mode 100644 index 0000000..15a72a2 --- /dev/null +++ b/DynamicIsland/components/Shelf/Views/LocalSendLiveActivity.swift @@ -0,0 +1,173 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI +import Defaults + +struct LocalSendLiveActivity: View { + @EnvironmentObject var vm: DynamicIslandViewModel + @StateObject private var localSend = LocalSendService.shared + + @State private var isHovering: Bool = false + @State private var isExpanded: Bool = false + + private var tint: Color { + switch localSend.transferState { + case .completed: + return .green + case .failed, .rejected: + return .red + default: + return .accentColor + } + } + + private var isActive: Bool { + localSend.isSending || localSend.transferState == .completed || + isFailedOrRejected + } + + private var isFailedOrRejected: Bool { + if case .failed = localSend.transferState { return true } + if case .rejected = localSend.transferState { return true } + return false + } + + var body: some View { + HStack(spacing: 0) { + // Left side: upload icon capsule + Color.clear + .background { + if isExpanded { + HStack { + ZStack { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(tint.opacity(0.14)) + + Image(systemName: leftIcon) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(tint) + } + .frame( + width: vm.effectiveClosedNotchHeight - 12, + height: vm.effectiveClosedNotchHeight - 12 + ) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + } + } + .frame( + width: isExpanded ? max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)) : 0, + height: vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + ) + + // Center: closed notch body (slightly wider during transfers) + Rectangle() + .fill(.black) + .frame( + width: vm.closedNotchSize.width + + (isHovering ? 8 : 0) + + (isActive ? 40 : 0) + ) + + // Right side: progress ring or status icon + Color.clear + .background { + if isExpanded { + HStack { + rightContent + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing) + } + } + .frame( + width: isExpanded ? max(60, vm.effectiveClosedNotchHeight) : 0, + height: vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + ) + } + .frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0)) + .onAppear { + withAnimation(.smooth(duration: 0.35)) { + isExpanded = true + } + } + .onChange(of: isActive) { _, newValue in + withAnimation(.smooth(duration: 0.35)) { + isExpanded = newValue + } + } + } + + private var leftIcon: String { + // Always show document upload icon on left side + return "document.badge.arrow.up.fill" + } + + @ViewBuilder + private var rightContent: some View { + switch localSend.transferState { + case .completed: + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + .font(.system(size: 16, weight: .semibold)) + .padding(.trailing, 6) + case .failed: + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.red) + .font(.system(size: 16, weight: .semibold)) + .padding(.trailing, 6) + case .rejected: + Image(systemName: "hand.raised.fill") + .foregroundStyle(.orange) + .font(.system(size: 16, weight: .semibold)) + .padding(.trailing, 6) + case .sending, .idle: + LocalSendProgressRing(progress: localSend.sendProgress) + .padding(.trailing, 6) + } + } +} + +// MARK: - Progress Ring + +private struct LocalSendProgressRing: View { + let progress: Double + + var body: some View { + ZStack { + Circle() + .stroke(Color.white.opacity(0.2), lineWidth: 2.5) + + Circle() + .trim(from: 0, to: min(max(progress, 0), 1)) + .stroke( + Color.accentColor, + style: StrokeStyle(lineWidth: 2.5, lineCap: .round) + ) + .rotationEffect(.degrees(-90)) + .animation(.easeInOut(duration: 0.2), value: progress) + + if progress > 0.99 { + Image(systemName: "checkmark") + .font(.system(size: 8, weight: .bold)) + .foregroundColor(.accentColor) + } + } + .frame(width: 20, height: 20) + } +} diff --git a/DynamicIsland/components/Tabs/TabSelectionView.swift b/DynamicIsland/components/Tabs/TabSelectionView.swift index 03a0d09..33939fc 100644 --- a/DynamicIsland/components/Tabs/TabSelectionView.swift +++ b/DynamicIsland/components/Tabs/TabSelectionView.swift @@ -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) diff --git a/DynamicIsland/managers/Extensions/ExtensionAuthorizationManager.swift b/DynamicIsland/managers/Extensions/ExtensionAuthorizationManager.swift index 2c3501c..c4ca4b6 100644 --- a/DynamicIsland/managers/Extensions/ExtensionAuthorizationManager.swift +++ b/DynamicIsland/managers/Extensions/ExtensionAuthorizationManager.swift @@ -111,6 +111,8 @@ final class ExtensionAuthorizationManager: ObservableObject { record.widgetTimestamps.append(now) case .notchExperiences: record.notchExperienceTimestamps.append(now) + case .fileSharing: + record.activityTimestamps.append(now) } record.activityTimestamps = flushOldTimestamps(record.activityTimestamps) record.widgetTimestamps = flushOldTimestamps(record.widgetTimestamps) diff --git a/DynamicIsland/managers/LocalSendDevicePickerWindowManager.swift b/DynamicIsland/managers/LocalSendDevicePickerWindowManager.swift new file mode 100644 index 0000000..387bdf1 --- /dev/null +++ b/DynamicIsland/managers/LocalSendDevicePickerWindowManager.swift @@ -0,0 +1,561 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import AppKit +import SwiftUI +import Defaults + +private func applyLocalSendPanelCornerMask(_ view: NSView, radius: CGFloat) { + view.wantsLayer = true + view.layer?.masksToBounds = true + view.layer?.cornerRadius = radius + view.layer?.backgroundColor = NSColor.clear.cgColor + if #available(macOS 13.0, *) { + view.layer?.cornerCurve = .continuous + } +} + +@MainActor +final class LocalSendDevicePickerWindowManager { + static let shared = LocalSendDevicePickerWindowManager() + + private var window: NSWindow? + private var onDeviceSelected: ((LocalSendDeviceInfo) -> Void)? + private var onDismiss: (() -> Void)? + + private init() {} + + func show( + onDeviceSelected: @escaping (LocalSendDeviceInfo) -> Void, + onDismiss: @escaping () -> Void + ) { + let cornerRadius: CGFloat = 24 + self.onDeviceSelected = onDeviceSelected + self.onDismiss = onDismiss + + let pickerView = LocalSendDevicePickerView( + onDeviceSelected: { [weak self] device in + self?.onDeviceSelected?(device) + self?.hide() + }, + onDismiss: { [weak self] in + self?.onDismiss?() + self?.hide() + } + ) + + if let existingWindow = window { + let hostingView = NSHostingView(rootView: pickerView) + applyLocalSendPanelCornerMask(hostingView, radius: cornerRadius) + existingWindow.contentView = hostingView + existingWindow.makeKeyAndOrderFront(nil) + return + } + + guard let screen = NSScreen.main else { return } + + let windowSize = CGSize(width: 480, height: 400) + let windowOrigin = CGPoint( + x: screen.frame.midX - windowSize.width / 2, + y: screen.frame.midY - windowSize.height / 2 + ) + + let newWindow = NSWindow( + contentRect: NSRect(origin: windowOrigin, size: windowSize), + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + + newWindow.isOpaque = false + newWindow.backgroundColor = .clear + newWindow.hasShadow = true + newWindow.level = .floating + newWindow.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + + let hostingView = NSHostingView(rootView: + pickerView + .background(Color.clear) + ) + applyLocalSendPanelCornerMask(hostingView, radius: cornerRadius) + newWindow.contentView = hostingView + newWindow.isMovableByWindowBackground = true + + window = newWindow + newWindow.makeKeyAndOrderFront(nil) + + // Add click-outside-to-dismiss + NSEvent.addLocalMonitorForEvents(matching: .leftMouseDown) { [weak self] event in + guard let self, let window = self.window else { return event } + let locationInWindow = event.locationInWindow + let windowFrame = window.frame + let clickLocation = NSPoint( + x: windowFrame.origin.x + locationInWindow.x, + y: windowFrame.origin.y + locationInWindow.y + ) + + if !windowFrame.contains(clickLocation) && event.window != window { + self.onDismiss?() + self.hide() + } + return event + } + } + + func hide() { + window?.orderOut(nil) + window = nil + onDeviceSelected = nil + onDismiss = nil + } +} + +// MARK: - LocalSend Device Picker View + +struct LocalSendDevicePickerView: View { + @StateObject private var localSend = LocalSendService.shared + @Default(.localSendDevicePickerGlassMode) private var glassMode + @Default(.localSendDevicePickerLiquidGlassVariant) private var liquidGlassVariant + + let onDeviceSelected: (LocalSendDeviceInfo) -> Void + let onDismiss: () -> Void + + @State private var hoveredDeviceID: String? + + private let cornerRadius: CGFloat = 24 + + private var usesCustomLiquidGlass: Bool { + glassMode == .customLiquid + } + + private var usesStandardLiquidGlass: Bool { + guard glassMode == .standard else { return false } + if #available(macOS 26.0, *) { + return true + } + return false + } + + var body: some View { + ZStack { + // Explicitly clear background for proper transparency + Color.clear + + // Background - clips to rounded rect + panelBackground + .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + + // Content + VStack(spacing: 16) { + // Header + HStack { + Image("LocalSend") + .resizable() + .scaledToFit() + .frame(width: 28, height: 28) + + Text("Select Device") + .font(.system(size: 18, weight: .semibold, design: .rounded)) + .foregroundColor(.white) + + Spacer() + + // Refresh button + Button { + localSend.refreshDeviceScan() + } label: { + Image(systemName: "arrow.clockwise") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.white.opacity(0.8)) + .rotationEffect(.degrees(localSend.isRefreshing ? 360 : 0)) + .animation( + localSend.isRefreshing + ? .linear(duration: 0.85).repeatForever(autoreverses: false) + : .easeOut(duration: 0.2), + value: localSend.isRefreshing + ) + .frame(width: 32, height: 32) + .background(Circle().fill(Color.white.opacity(0.1))) + } + .buttonStyle(.plain) + .disabled(localSend.isRefreshing) + .help("Refresh devices") + + // Close button + Button { + onDismiss() + } label: { + Image(systemName: "xmark") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(.white.opacity(0.6)) + .frame(width: 28, height: 28) + .background(Circle().fill(Color.white.opacity(0.1))) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 20) + .padding(.top, 20) + + // Devices grid + if localSend.devices.isEmpty { + emptyStateView + } else { + devicesGridView + } + } + } + .frame(width: 480, height: 400) + .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: cornerRadius) + .stroke(Color.white.opacity(0.2), lineWidth: 1) + } + .shadow(color: Color.black.opacity(0.4), radius: 30, x: 0, y: 15) + .compositingGroup() + .onAppear { + localSend.startDiscovery() + localSend.refreshDeviceScan() + } + } + + @ViewBuilder + private var panelBackground: some View { + if usesCustomLiquidGlass { + LiquidGlassBackground(variant: liquidGlassVariant, cornerRadius: cornerRadius) { + Color.black.opacity(0.3) + } + } else if usesStandardLiquidGlass { + if #available(macOS 26.0, *) { + LocalSendGlassBackdrop(cornerRadius: cornerRadius) + } + } else { + RoundedRectangle(cornerRadius: cornerRadius) + .fill(.ultraThinMaterial) + .background( + RoundedRectangle(cornerRadius: cornerRadius) + .fill(Color.black.opacity(0.4)) + ) + } + } + + private var emptyStateView: some View { + VStack(spacing: 12) { + Spacer() + + if localSend.isRefreshing { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .scaleEffect(1.2) + + Text("Scanning for devices...") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.white.opacity(0.7)) + } else { + Image(systemName: "antenna.radiowaves.left.and.right.slash") + .font(.system(size: 48, weight: .light)) + .foregroundColor(.white.opacity(0.4)) + + Text("No devices found") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.white.opacity(0.7)) + + Text("Make sure LocalSend is running on other devices") + .font(.system(size: 12)) + .foregroundColor(.white.opacity(0.5)) + .multilineTextAlignment(.center) + + Button { + localSend.refreshDeviceScan() + } label: { + Text("Scan Again") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.white) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background( + Capsule() + .fill(Color.accentColor.opacity(0.8)) + ) + } + .buttonStyle(.plain) + .padding(.top, 8) + } + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var devicesGridView: some View { + ScrollView { + LazyVGrid( + columns: [ + GridItem(.flexible(), spacing: 16), + GridItem(.flexible(), spacing: 16), + GridItem(.flexible(), spacing: 16) + ], + spacing: 16 + ) { + ForEach(localSend.devices) { device in + DeviceGridItem( + device: device, + isHovered: hoveredDeviceID == device.id, + isRejected: localSend.rejectedDeviceIDs.contains(device.id), + onSelect: { + localSend.clearRejectedStatus(for: device.id) + onDeviceSelected(device) + } + ) + .onHover { hovering in + hoveredDeviceID = hovering ? device.id : nil + } + } + } + .padding(.horizontal, 20) + .padding(.bottom, 20) + } + } +} + +// MARK: - Device Grid Item + +private struct DeviceGridItem: View { + let device: LocalSendDeviceInfo + let isHovered: Bool + let isRejected: Bool + let onSelect: () -> Void + + var body: some View { + Button(action: onSelect) { + VStack(spacing: 10) { + // Device icon + ZStack { + Circle() + .fill(isRejected ? Color.red.opacity(0.15) : Color.white.opacity(isHovered ? 0.15 : 0.08)) + .frame(width: 56, height: 56) + + Image(systemName: deviceIcon) + .font(.system(size: 24, weight: .medium)) + .foregroundColor(isRejected ? .red : (isHovered ? .accentColor : .white.opacity(0.8))) + } + + // Device name + VStack(spacing: 2) { + Text(device.alias) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(isRejected ? .red : .white) + .lineLimit(1) + .truncationMode(.tail) + + if isRejected { + Text("Rejected") + .font(.system(size: 10, weight: .medium)) + .foregroundColor(.red.opacity(0.8)) + } else if let model = device.model, !model.isEmpty { + Text(model) + .font(.system(size: 10)) + .foregroundColor(.white.opacity(0.5)) + .lineLimit(1) + .truncationMode(.tail) + } + } + } + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(isRejected ? Color.red.opacity(0.08) : Color.white.opacity(isHovered ? 0.12 : 0.06)) + ) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(isHovered ? Color.accentColor.opacity(0.6) : Color.white.opacity(0.1), lineWidth: 1) + ) + } + .buttonStyle(.plain) + .scaleEffect(isHovered ? 1.02 : 1.0) + .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isHovered) + } + + private var deviceIcon: String { + let aliasLower = device.alias.lowercased() + let modelLower = (device.model ?? "").lowercased() + let combined = aliasLower + " " + modelLower + + // iPhone / iOS devices + if combined.contains("iphone") { + return "iphone" + } + if combined.contains("ipad") { + return "ipad" + } + if combined.contains("ipod") { + return "ipodtouch" + } + + // Android phones by brand + if combined.contains("pixel") || combined.contains("nexus") { + return "smartphone" + } + if combined.contains("samsung") || combined.contains("galaxy") { + return "smartphone" + } + if combined.contains("oneplus") || combined.contains("one plus") { + return "smartphone" + } + if combined.contains("xiaomi") || combined.contains("redmi") || combined.contains("poco") || combined.contains("mi ") { + return "smartphone" + } + if combined.contains("huawei") || combined.contains("honor") { + return "smartphone" + } + if combined.contains("oppo") || combined.contains("realme") || combined.contains("vivo") { + return "smartphone" + } + if combined.contains("motorola") || combined.contains("moto ") { + return "smartphone" + } + if combined.contains("nokia") { + return "smartphone" + } + if combined.contains("sony") || combined.contains("xperia") { + return "smartphone" + } + if combined.contains("lg") { + return "smartphone" + } + if combined.contains("asus") && (combined.contains("phone") || combined.contains("rog")) { + return "smartphone" + } + if combined.contains("nothing") && combined.contains("phone") { + return "smartphone" + } + + // Android tablets + if combined.contains("tab") || combined.contains("tablet") { + return "tablet.portrait" + } + + // Mac devices + if combined.contains("macbook") { + return "laptopcomputer" + } + if combined.contains("imac") { + return "desktopcomputer" + } + if combined.contains("mac mini") || combined.contains("mini") && combined.contains("mac") { + return "macmini" + } + if combined.contains("mac pro") { + return "macpro.gen3" + } + if combined.contains("mac studio") { + return "macstudio" + } + if combined.contains("mac") || combined.contains("macos") { + return "desktopcomputer" + } + + // Windows/Linux laptops and desktops + if combined.contains("laptop") || combined.contains("notebook") { + return "laptopcomputer" + } + if combined.contains("thinkpad") || combined.contains("lenovo") { + return "laptopcomputer" + } + if combined.contains("dell") || combined.contains("xps") || combined.contains("inspiron") || combined.contains("latitude") { + return "laptopcomputer" + } + if combined.contains("hp") || combined.contains("pavilion") || combined.contains("envy") || combined.contains("spectre") || combined.contains("elitebook") { + return "laptopcomputer" + } + if combined.contains("asus") || combined.contains("zenbook") || combined.contains("vivobook") { + return "laptopcomputer" + } + if combined.contains("acer") || combined.contains("aspire") || combined.contains("swift") { + return "laptopcomputer" + } + if combined.contains("msi") { + return "laptopcomputer" + } + if combined.contains("surface") { + return "laptopcomputer" + } + if combined.contains("razer") { + return "laptopcomputer" + } + if combined.contains("chromebook") { + return "laptopcomputer" + } + + // Desktop keywords + if combined.contains("desktop") || combined.contains("pc") || combined.contains("workstation") { + return "desktopcomputer" + } + if combined.contains("windows") || combined.contains("linux") || combined.contains("ubuntu") || combined.contains("fedora") { + return "desktopcomputer" + } + + // Android generic + if combined.contains("android") { + return "smartphone" + } + + // Phone/Mobile generic + if combined.contains("phone") || combined.contains("mobile") { + return "smartphone" + } + + // TV devices + if combined.contains("tv") || combined.contains("shield") || combined.contains("fire") || combined.contains("roku") || combined.contains("chromecast") { + return "appletv" + } + + // Default: try to guess based on common patterns + // If it looks like a computer name with a person's name, assume laptop + if combined.contains("'s ") || combined.contains("-pc") || combined.contains("-laptop") { + return "laptopcomputer" + } + + // Default fallback + return "smartphone" + } +} + +// MARK: - Glass Backdrop for LocalSend Picker + +@available(macOS 26.0, *) +private struct LocalSendGlassBackdrop: View { + let cornerRadius: CGFloat + + var body: some View { + GeometryReader { proxy in + let dynamicFontSize = max(min(proxy.size.width, proxy.size.height) / 8, 42) + + Text("LocalSend Device Picker") + .font(.system(size: dynamicFontSize, weight: .semibold, design: .rounded)) + .foregroundStyle(Color.clear) + .frame(width: proxy.size.width, height: proxy.size.height) + .glassEffect( + .clear.interactive(), + in: .rect(cornerRadius: cornerRadius) + ) + } + .allowsHitTesting(false) + .accessibilityHidden(true) + } +} diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 628253f..c03afc5 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -143,12 +143,14 @@ enum ExtensionPermissionScope: String, CaseIterable, Codable, Defaults.Serializa case liveActivities case lockScreenWidgets case notchExperiences + case fileSharing var displayName: String { switch self { case .liveActivities: return "Live Activities" case .lockScreenWidgets: return "Lock Screen Widgets" case .notchExperiences: return "Notch Experiences" + case .fileSharing: return "File Sharing" } } } @@ -886,6 +888,9 @@ extension Defaults.Keys { static let dynamicShelf = Key("dynamicShelf", default: true) static let openShelfByDefault = Key("openShelfByDefault", default: true) static let quickShareProvider = Key("quickShareProvider", default: "AirDrop") + static let localSendSelectedDeviceID = Key("localSendSelectedDeviceID", default: "") + static let localSendDevicePickerGlassMode = Key("localSendDevicePickerGlassMode", default: .standard) + static let localSendDevicePickerLiquidGlassVariant = Key("localSendDevicePickerLiquidGlassVariant", default: .v11) static let copyOnDrag = Key("copyOnDrag", default: false) static let autoRemoveShelfItems = Key("autoRemoveShelfItems", default: false) static let expandedDragDetection = Key("expandedDragDetection", default: true) @@ -1003,6 +1008,7 @@ extension Defaults.Keys { static let extensionLiveActivityCapacity = Key("extensionLiveActivityCapacity", default: 4) static let extensionLockScreenWidgetCapacity = Key("extensionLockScreenWidgetCapacity", default: 4) static let extensionNotchExperienceCapacity = Key("extensionNotchExperienceCapacity", default: 2) + static let enableExtensionFileSharing = Key("enableExtensionFileSharing", default: true) // MARK: Keyboard Shortcuts static let enableShortcuts = Key("enableShortcuts", default: true) diff --git a/DynamicIsland/services/Extensions/ExtensionRPCServer.swift b/DynamicIsland/services/Extensions/ExtensionRPCServer.swift index 13a9dd2..c1bc1b5 100644 --- a/DynamicIsland/services/Extensions/ExtensionRPCServer.swift +++ b/DynamicIsland/services/Extensions/ExtensionRPCServer.swift @@ -29,6 +29,7 @@ final class ExtensionRPCServer { private var listener: NWListener? private var connections: [UUID: RPCClientConnection] = [:] + private var shelfSubscribers: Set = [] // bundleIdentifiers subscribed to shelf events private let port: UInt16 = 9020 private let queue = DispatchQueue(label: "com.ebullioscopic.Atoll.rpc.server", qos: .userInitiated) private let decoder = JSONDecoder() @@ -78,6 +79,7 @@ final class ExtensionRPCServer { conn.connection.cancel() } connections.removeAll() + shelfSubscribers.removeAll() 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 private func handleListenerState(_ state: NWListener.State) { diff --git a/DynamicIsland/services/Extensions/ExtensionRPCService.swift b/DynamicIsland/services/Extensions/ExtensionRPCService.swift index 383011e..a72757a 100644 --- a/DynamicIsland/services/Extensions/ExtensionRPCService.swift +++ b/DynamicIsland/services/Extensions/ExtensionRPCService.swift @@ -17,8 +17,10 @@ */ import Foundation +import AppKit import Defaults import AtollExtensionKit +import UniformTypeIdentifiers /// Handles JSON-RPC method calls for a single WebSocket connection. /// Mirrors the functionality of `ExtensionXPCService` but uses JSON-RPC transport. @@ -94,6 +96,25 @@ final class ExtensionRPCService { case "atoll.dismissNotchExperience": 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: result = RPCErrorResponse( error: RPCErrorObject(code: RPCErrorCode.methodNotFound, message: "Method not found: \(request.method)"), @@ -327,7 +348,7 @@ final class ExtensionRPCService { case .duplicateIdentifier: code = RPCErrorCode.descriptorInvalid } return RPCErrorResponse( - error: RPCErrorObject(code: code, message: error.localizedDescription ?? "Unknown error"), + error: RPCErrorObject(code: code, message: error.localizedDescription), id: id ) } @@ -337,6 +358,232 @@ final class ExtensionRPCService { 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 /// Converts client wire format to Swift Codable format for enum types. @@ -452,3 +699,12 @@ final class ExtensionRPCService { 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 + } +}