From d8a99c12c070272b8a90f7564424f484441e4bdc Mon Sep 17 00:00:00 2001 From: hkay-dev <14947763+hkay-dev@users.noreply.github.com> Date: Wed, 23 Sep 2026 02:28:40 -0700 Subject: [PATCH 1/4] Add macOS Share menu handoff to Quick Share --- AirSync.xcodeproj/project.pbxproj | 171 ++++++++++++++++ AirSyncShareExtension.entitlements | 14 ++ AirSyncShareExtension/Info.plist | 33 ++++ AirSyncShareExtension/ShareInputLoader.swift | 183 ++++++++++++++++++ .../ShareViewController.swift | 106 ++++++++++ README.md | 7 + airsync-mac/Core/AppDelegate.swift | 2 +- 7 files changed, 515 insertions(+), 1 deletion(-) create mode 100644 AirSyncShareExtension.entitlements create mode 100644 AirSyncShareExtension/Info.plist create mode 100644 AirSyncShareExtension/ShareInputLoader.swift create mode 100644 AirSyncShareExtension/ShareViewController.swift diff --git a/AirSync.xcodeproj/project.pbxproj b/AirSync.xcodeproj/project.pbxproj index 92c1d201..d429f8f1 100644 --- a/AirSync.xcodeproj/project.pbxproj +++ b/AirSync.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + A17000000000000000000001 /* AirSyncShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = A17000000000000000000004 /* AirSyncShareExtension.appex */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; B94971602F62509A00F59BC9 /* BigInt in Frameworks */ = {isa = PBXBuildFile; productRef = B949715F2F62509A00F59BC9 /* BigInt */; }; B94971632F6250DE00F59BC9 /* ASN1 in Frameworks */ = {isa = PBXBuildFile; productRef = B94971622F6250DE00F59BC9 /* ASN1 */; }; B94971662F62514500F59BC9 /* SwiftECC in Frameworks */ = {isa = PBXBuildFile; productRef = B94971652F62514500F59BC9 /* SwiftECC */; }; @@ -26,6 +27,13 @@ /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + A17000000000000000000002 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = B9673B4F2E35A2A1006D284A /* Project object */; + proxyType = 1; + remoteGlobalIDString = A17000000000000000000008; + remoteInfo = AirSyncShareExtension; + }; B99D64073059A795003D951A /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = B9673B4F2E35A2A1006D284A /* Project object */; @@ -43,6 +51,7 @@ dstSubfolderSpec = 13; files = ( B99D64093059A795003D951A /* AirSyncWidgetExtension.appex in Embed Foundation Extensions */, + A17000000000000000000001 /* AirSyncShareExtension.appex in Embed Foundation Extensions */, ); name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; @@ -50,6 +59,8 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + A17000000000000000000004 /* AirSyncShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = AirSyncShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + A17000000000000000000010 /* AirSyncShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = AirSyncShareExtension.entitlements; sourceTree = ""; }; B9673B572E35A2A1006D284A /* AirSync.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AirSync.app; sourceTree = BUILT_PRODUCTS_DIR; }; B99B04C52F641E1400FF3E2D /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = AppIcon.icon; sourceTree = ""; }; B99D63F83059A794003D951A /* AirSyncWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = AirSyncWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -61,6 +72,13 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + A17000000000000000000005 /* Exceptions for "AirSyncShareExtension" folder in "AirSyncShareExtension" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = A17000000000000000000008 /* AirSyncShareExtension */; + }; B933ED362E3BEC21007393CB /* Exceptions for "airsync-mac" folder in "AirSync" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( @@ -90,6 +108,14 @@ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ + A17000000000000000000006 /* AirSyncShareExtension */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + A17000000000000000000005 /* Exceptions for "AirSyncShareExtension" folder in "AirSyncShareExtension" target */, + ); + path = AirSyncShareExtension; + sourceTree = ""; + }; B9673B592E35A2A1006D284A /* airsync-mac */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( @@ -109,6 +135,13 @@ /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ + A17000000000000000000007 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; B9673B542E35A2A1006D284A /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -142,10 +175,12 @@ B9673B4E2E35A2A1006D284A = { isa = PBXGroup; children = ( + A17000000000000000000010 /* AirSyncShareExtension.entitlements */, B99D64153059A9FC003D951A /* AirSyncWidgetExtension.entitlements */, B99D64143059A9B3003D951A /* AirSync.entitlements */, B9673B592E35A2A1006D284A /* airsync-mac */, B99D63FD3059A794003D951A /* AirSyncWidget */, + A17000000000000000000006 /* AirSyncShareExtension */, B9D263192F60D97900628704 /* Frameworks */, B9673B582E35A2A1006D284A /* Products */, B99B04C52F641E1400FF3E2D /* AppIcon.icon */, @@ -158,6 +193,7 @@ children = ( B9673B572E35A2A1006D284A /* AirSync.app */, B99D63F83059A794003D951A /* AirSyncWidgetExtension.appex */, + A17000000000000000000004 /* AirSyncShareExtension.appex */, ); name = Products; sourceTree = ""; @@ -174,6 +210,28 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + A17000000000000000000008 /* AirSyncShareExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = A1700000000000000000000F /* Build configuration list for PBXNativeTarget "AirSyncShareExtension" */; + buildPhases = ( + A1700000000000000000000A /* Sources */, + A17000000000000000000007 /* Frameworks */, + A17000000000000000000009 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + A17000000000000000000006 /* AirSyncShareExtension */, + ); + name = AirSyncShareExtension; + packageProductDependencies = ( + ); + productName = AirSyncShareExtension; + productReference = A17000000000000000000004 /* AirSyncShareExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; B9673B562E35A2A1006D284A /* AirSync */ = { isa = PBXNativeTarget; buildConfigurationList = B9673B622E35A2A3006D284A /* Build configuration list for PBXNativeTarget "AirSync" */; @@ -187,6 +245,7 @@ ); dependencies = ( B99D64083059A795003D951A /* PBXTargetDependency */, + A1700000000000000000000B /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( B9673B592E35A2A1006D284A /* airsync-mac */, @@ -241,6 +300,9 @@ LastSwiftUpdateCheck = 2700; LastUpgradeCheck = 2620; TargetAttributes = { + A17000000000000000000008 = { + CreatedOnToolsVersion = 27.0; + }; B9673B562E35A2A1006D284A = { CreatedOnToolsVersion = 26.0; }; @@ -277,11 +339,19 @@ targets = ( B9673B562E35A2A1006D284A /* AirSync */, B99D63F73059A794003D951A /* AirSyncWidgetExtension */, + A17000000000000000000008 /* AirSyncShareExtension */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + A17000000000000000000009 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; B9673B552E35A2A1006D284A /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -301,6 +371,13 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + A1700000000000000000000A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; B9673B532E35A2A1006D284A /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -318,6 +395,11 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + A1700000000000000000000B /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = A17000000000000000000008 /* AirSyncShareExtension */; + targetProxy = A17000000000000000000002 /* PBXContainerItemProxy */; + }; B99D64083059A795003D951A /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = B99D63F73059A794003D951A /* AirSyncWidgetExtension */; @@ -326,6 +408,85 @@ /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ + A1700000000000000000000C /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_ENTITLEMENTS = AirSyncShareExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 32; + DEVELOPMENT_TEAM = WCB4HTANA6; + ENABLE_APP_SANDBOX = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = AirSyncShareExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = AirSync; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../../../../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 14.5; + MARKETING_VERSION = 4.1.0; + PRODUCT_BUNDLE_IDENTIFIER = "sameerasw.airsync-mac.share"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + A1700000000000000000000D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_ENTITLEMENTS = AirSyncShareExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 32; + DEVELOPMENT_TEAM = WCB4HTANA6; + ENABLE_APP_SANDBOX = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = AirSyncShareExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = AirSync; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../../../../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 14.5; + MARKETING_VERSION = 4.1.0; + PRODUCT_BUNDLE_IDENTIFIER = "sameerasw.airsync-mac.share"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + A1700000000000000000000E /* Self Compiled */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_ENTITLEMENTS = AirSyncShareExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 32; + DEVELOPMENT_TEAM = WCB4HTANA6; + ENABLE_APP_SANDBOX = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = AirSyncShareExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = AirSync; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../../../../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 14.5; + MARKETING_VERSION = 4.1.0; + PRODUCT_BUNDLE_IDENTIFIER = "sameerasw.airsync-mac.share"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + }; + name = "Self Compiled"; + }; 11F6093B2EA2A88900AB6BDC /* Self Compiled */ = { isa = XCBuildConfiguration; baseConfigurationReferenceAnchor = B9673B592E35A2A1006D284A /* airsync-mac */; @@ -780,6 +941,16 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + A1700000000000000000000F /* Build configuration list for PBXNativeTarget "AirSyncShareExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A1700000000000000000000C /* Debug */, + A1700000000000000000000D /* Release */, + A1700000000000000000000E /* Self Compiled */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; B9673B522E35A2A1006D284A /* Build configuration list for PBXProject "AirSync" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/AirSyncShareExtension.entitlements b/AirSyncShareExtension.entitlements new file mode 100644 index 00000000..f835e023 --- /dev/null +++ b/AirSyncShareExtension.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.application-groups + + group.sameerasw.airsync-mac.widget + + com.apple.security.files.user-selected.read-only + + + diff --git a/AirSyncShareExtension/Info.plist b/AirSyncShareExtension/Info.plist new file mode 100644 index 00000000..856434a2 --- /dev/null +++ b/AirSyncShareExtension/Info.plist @@ -0,0 +1,33 @@ + + + + + NSExtension + + NSExtensionAttributes + + NSExtensionActivationRule + + NSExtensionActivationDictionaryVersion + 2 + NSExtensionActivationSupportsAttachmentsWithMaxCount + 999 + NSExtensionActivationSupportsFileWithMaxCount + 999 + NSExtensionActivationSupportsImageWithMaxCount + 999 + NSExtensionActivationSupportsMovieWithMaxCount + 999 + NSExtensionActivationSupportsText + + NSExtensionActivationSupportsWebURLWithMaxCount + 999 + + + NSExtensionPointIdentifier + com.apple.share-services + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).ShareViewController + + + diff --git a/AirSyncShareExtension/ShareInputLoader.swift b/AirSyncShareExtension/ShareInputLoader.swift new file mode 100644 index 00000000..cb0491a5 --- /dev/null +++ b/AirSyncShareExtension/ShareInputLoader.swift @@ -0,0 +1,183 @@ +import Foundation +import UniformTypeIdentifiers + +struct ShareInputLoader { + enum InputError: LocalizedError { + case empty + case unsupported + case folder + + var errorDescription: String? { + switch self { + case .empty: "There is nothing to send." + case .unsupported: "AirSync cannot read one of the shared items." + case .folder: "AirSync cannot send folders yet." + } + } + } + + private let requestDirectory: URL + + init(groupContainer: URL) { + requestDirectory = groupContainer + .appendingPathComponent("Library/Caches/AirSyncShares", isDirectory: true) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + } + + func prepare(_ items: [NSExtensionItem]) async throws -> [URL] { + removeOldRequests() + do { + var urls: [URL] = [] + for item in items { + let attachments = item.attachments ?? [] + if attachments.isEmpty { + if let text = item.attributedContentText?.string, !text.isEmpty { + urls.append(try stage(Data(text.utf8), name: "Shared Text.txt")) + } + } else { + for provider in attachments { + urls.append(try await prepare(provider)) + } + } + } + guard !urls.isEmpty else { throw InputError.empty } + return urls + } catch { + discard() + throw error + } + } + + func discard() { + try? FileManager.default.removeItem(at: requestDirectory) + } + + private func prepare(_ provider: NSItemProvider) async throws -> URL { + if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier), + let url = try? await loadURL(provider) { + if url.isFileURL { + try checkForFolder(url) + return url + } + return try stage(Data(url.absoluteString.utf8), name: "Shared Link.txt") + } + + let fileType = provider.registeredTypeIdentifiers.first { + guard let type = UTType($0) else { return false } + return type.conforms(to: .data) && !type.conforms(to: .text) && !type.conforms(to: .url) + } + if let fileType { + do { + return try await stageFile(provider, typeIdentifier: fileType) + } catch InputError.folder { + throw InputError.folder + } catch { + let data = try await loadData(provider, typeIdentifier: fileType) + return try stage(data, name: fileName(provider.suggestedName, typeIdentifier: fileType)) + } + } + + if let textType = provider.registeredTypeIdentifiers.first(where: { + UTType($0)?.conforms(to: .text) == true + }) { + let data = try await loadData(provider, typeIdentifier: textType) + return try stage(data, name: fileName(provider.suggestedName, typeIdentifier: textType, fallback: "Shared Text")) + } + throw InputError.unsupported + } + + private func loadURL(_ provider: NSItemProvider) async throws -> URL { + try await withCheckedThrowingContinuation { continuation in + _ = provider.loadObject(ofClass: NSURL.self) { url, error in + if let error { + continuation.resume(throwing: error) + } else if let url = url as? NSURL { + continuation.resume(returning: url as URL) + } else { + continuation.resume(throwing: InputError.unsupported) + } + } + } + } + + private func loadData(_ provider: NSItemProvider, typeIdentifier: String) async throws -> Data { + try await withCheckedThrowingContinuation { continuation in + _ = provider.loadDataRepresentation(forTypeIdentifier: typeIdentifier) { data, error in + if let error { + continuation.resume(throwing: error) + } else if let data { + continuation.resume(returning: data) + } else { + continuation.resume(throwing: InputError.unsupported) + } + } + } + } + + private func stageFile(_ provider: NSItemProvider, typeIdentifier: String) async throws -> URL { + let suggestedName = provider.suggestedName + return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + _ = provider.loadFileRepresentation(forTypeIdentifier: typeIdentifier) { source, error in + if let error { + continuation.resume(throwing: error) + return + } + guard let source else { + continuation.resume(throwing: InputError.unsupported) + return + } + do { + try checkForFolder(source) + let name = fileName(suggestedName, typeIdentifier: typeIdentifier, fallback: source.lastPathComponent) + let destination = try destination(for: name) + try FileManager.default.copyItem(at: source, to: destination) + continuation.resume(returning: destination) + } catch { + continuation.resume(throwing: error) + } + } + } + } + + private func stage(_ data: Data, name: String) throws -> URL { + let destination = try destination(for: name) + try data.write(to: destination, options: .atomic) + return destination + } + + private func destination(for name: String) throws -> URL { + let itemDirectory = requestDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: itemDirectory, withIntermediateDirectories: true) + return itemDirectory.appendingPathComponent(name) + } + + private func fileName(_ suggestedName: String?, typeIdentifier: String, fallback: String = "Shared Item") -> String { + let suggested = suggestedName ?? fallback + let name = (suggested as NSString).lastPathComponent + let safeName = name.isEmpty || name == "." || name == ".." ? fallback : name + guard (safeName as NSString).pathExtension.isEmpty, + let ext = UTType(typeIdentifier)?.preferredFilenameExtension else { return safeName } + return "\(safeName).\(ext)" + } + + private func checkForFolder(_ url: URL) throws { + if url.hasDirectoryPath || (try? url.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true { + throw InputError.folder + } + } + + private func removeOldRequests() { + let root = requestDirectory.deletingLastPathComponent() + guard let children = try? FileManager.default.contentsOfDirectory( + at: root, + includingPropertiesForKeys: [.contentModificationDateKey] + ) else { return } + let cutoff = Date().addingTimeInterval(-24 * 60 * 60) + for child in children where child != requestDirectory { + if let values = try? child.resourceValues(forKeys: [.contentModificationDateKey]), + let date = values.contentModificationDate, date < cutoff { + try? FileManager.default.removeItem(at: child) + } + } + } +} diff --git a/AirSyncShareExtension/ShareViewController.swift b/AirSyncShareExtension/ShareViewController.swift new file mode 100644 index 00000000..4f2085b1 --- /dev/null +++ b/AirSyncShareExtension/ShareViewController.swift @@ -0,0 +1,106 @@ +import AppKit + +final class ShareViewController: NSViewController { + private lazy var detail = NSTextField(wrappingLabelWithString: "AirSync will send this to your connected device or let you pick one.") + private lazy var sendButton = NSButton(title: "Send with AirSync", target: self, action: #selector(send)) + private lazy var cancelButton = NSButton(title: "Cancel", target: self, action: #selector(cancel)) + private var sendTask: Task? + + init() { + super.init(nibName: nil, bundle: nil) + preferredContentSize = NSSize(width: 360, height: 150) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError() + } + + override func loadView() { + let title = NSTextField(wrappingLabelWithString: "Send with AirSync") + title.font = .systemFont(ofSize: 17, weight: .semibold) + detail.textColor = .secondaryLabelColor + sendButton.keyEquivalent = "\r" + cancelButton.keyEquivalent = "\u{1b}" + + let buttons = NSStackView(views: [sendButton, cancelButton]) + buttons.orientation = .horizontal + buttons.spacing = 8 + + let stack = NSStackView(views: [title, detail, buttons]) + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 12 + stack.translatesAutoresizingMaskIntoConstraints = false + + let content = NSView(frame: NSRect(origin: .zero, size: preferredContentSize)) + content.addSubview(stack) + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20), + stack.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -20), + stack.centerYAnchor.constraint(equalTo: content.centerYAnchor), + ]) + view = content + } + + @objc private func send() { + guard sendTask == nil else { return } + sendButton.isEnabled = false + detail.stringValue = "Preparing this share for AirSync..." + sendTask = Task { @MainActor in + await handOff() + } + } + + private func handOff() async { + guard let items = extensionContext?.inputItems as? [NSExtensionItem], + let group = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: "group.sameerasw.airsync-mac.widget" + ) else { + showError("AirSync could not access the shared items.") + return + } + + let loader = ShareInputLoader(groupContainer: group) + var openingStarted = false + do { + let urls = try await loader.prepare(items) + try Task.checkCancellation() + detail.stringValue = "Opening AirSync..." + let appURL = Bundle.main.bundleURL + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let configuration = NSWorkspace.OpenConfiguration() + configuration.allowsRunningApplicationSubstitution = false + openingStarted = true + _ = try await NSWorkspace.shared.open( + urls, + withApplicationAt: appURL, + configuration: configuration + ) + try Task.checkCancellation() + extensionContext?.completeRequest(returningItems: [], completionHandler: nil) + } catch is CancellationError { + if !openingStarted { loader.discard() } + } catch { + if !openingStarted { loader.discard() } + if !Task.isCancelled { showError(error.localizedDescription) } + } + } + + private func showError(_ message: String) { + detail.stringValue = message + sendButton.isEnabled = true + cancelButton.isEnabled = true + sendTask = nil + } + + @objc private func cancel() { + guard cancelButton.isEnabled else { return } + sendTask?.cancel() + extensionContext?.cancelRequest( + withError: NSError(domain: NSCocoaErrorDomain, code: NSUserCancelledError) + ) + } +} diff --git a/README.md b/README.md index 7a787859..bf94078a 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,13 @@ ## Building Locally In XCode, select the `AirSync Self Compiled` Scheme. Then at the menu bar, select `Product -> Archive`. Once this process is finished, the organizer window will pop up. Select `Distribute`, then `Custom`. Select `Copy App`, then select the folder for output. `AirSync.app` will be located in that folder. +## Share menu +The macOS Share extension hands files to AirSync's existing Quick Share transfer. It uses the connected device when one is available, or opens device selection otherwise. Images and videos supplied as temporary data are copied to the existing app group's cache first. Text and web links are sent as text files. Cached copies older than 24 hours are removed on a later share. + +The current file sender does not support folders. macOS and each sharing app control menu placement, so AirSync cannot force a position above AirDrop or appear for every item. + +For release signing, enable the existing `group.sameerasw.airsync-mac.widget` app group for the new Share extension identifier in the Apple Developer account. Sign the app and its extensions with the same team. + ## Contributors <3 diff --git a/airsync-mac/Core/AppDelegate.swift b/airsync-mac/Core/AppDelegate.swift index 5ddde499..c1143a37 100644 --- a/airsync-mac/Core/AppDelegate.swift +++ b/airsync-mac/Core/AppDelegate.swift @@ -54,7 +54,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { func application(_ application: NSApplication, open urls: [URL]) { if !urls.isEmpty { QuickShareManager.shared.transferURLs = urls - QuickShareManager.shared.startDiscovery(autoTargetName: nil) + QuickShareManager.shared.startDiscovery(autoTargetName: AppState.shared.device?.name) AppState.shared.showingQuickShareTransfer = true } } From 1ac467c096b1aea59dcd70dc1800bc6a60dfe30d Mon Sep 17 00:00:00 2001 From: sameerasw Date: Tue, 13 Oct 2026 22:56:26 +0530 Subject: [PATCH 2/4] Show item previews and auto-send from the Share sheet Co-Authored-By: Claude Sonnet 5.5 --- .../SharePreviewLoader.swift | 182 ++++++++++++++++++ AirSyncShareExtension/ShareView.swift | 159 +++++++++++++++ .../ShareViewController.swift | 86 +++++---- 3 files changed, 392 insertions(+), 35 deletions(-) create mode 100644 AirSyncShareExtension/SharePreviewLoader.swift create mode 100644 AirSyncShareExtension/ShareView.swift diff --git a/AirSyncShareExtension/SharePreviewLoader.swift b/AirSyncShareExtension/SharePreviewLoader.swift new file mode 100644 index 00000000..44b1489f --- /dev/null +++ b/AirSyncShareExtension/SharePreviewLoader.swift @@ -0,0 +1,182 @@ +import AppKit +import QuickLookThumbnailing +import UniformTypeIdentifiers + +struct SharePreviewItem: Identifiable { + let id = UUID() + let name: String + let detail: String + let icon: NSImage + let thumbnail: NSImage? + let byteCount: Int64? + let isFolder: Bool +} + +@MainActor +struct SharePreviewLoader { + static let thumbnailByteLimit: Int64 = 25 * 1024 * 1024 + private let thumbnailSize = CGSize(width: 96, height: 96) + + func load(_ items: [NSExtensionItem]) async -> [SharePreviewItem] { + var previews: [SharePreviewItem] = [] + for item in items { + let attachments = item.attachments ?? [] + if attachments.isEmpty { + if let text = item.attributedContentText?.string, !text.isEmpty { + previews.append(textPreview(text, name: "Shared Text")) + } + } else { + for provider in attachments { + if let preview = await preview(provider) { + previews.append(preview) + } + } + } + } + return previews + } + + private func preview(_ provider: NSItemProvider) async -> SharePreviewItem? { + if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier), + let url = try? await loadURL(provider) { + return url.isFileURL ? await filePreview(url) : linkPreview(url) + } + + let identifiers = provider.registeredTypeIdentifiers + if let imageType = identifiers.first(where: { UTType($0)?.conforms(to: .image) == true }) { + return await imagePreview(provider, typeIdentifier: imageType) + } + if let fileType = identifiers.first(where: { + guard let type = UTType($0) else { return false } + return type.conforms(to: .data) && !type.conforms(to: .text) && !type.conforms(to: .url) + }) { + let type = UTType(fileType) + return SharePreviewItem( + name: provider.suggestedName ?? "Shared Item", + detail: type?.localizedDescription ?? "File", + icon: type.map { NSWorkspace.shared.icon(for: $0) } ?? NSImage(named: NSImage.multipleDocumentsName) ?? NSImage(), + thumbnail: nil, + byteCount: nil, + isFolder: false + ) + } + if let textType = identifiers.first(where: { UTType($0)?.conforms(to: .text) == true }), + let data = try? await loadData(provider, typeIdentifier: textType), + let text = String(data: data, encoding: .utf8) { + return textPreview(text, name: provider.suggestedName ?? "Shared Text") + } + return nil + } + + private func filePreview(_ url: URL) async -> SharePreviewItem { + let values = try? url.resourceValues(forKeys: [.isDirectoryKey, .fileSizeKey, .localizedTypeDescriptionKey]) + let isFolder = values?.isDirectory == true || url.hasDirectoryPath + let size = values?.fileSize.map(Int64.init) + let kind = values?.localizedTypeDescription ?? "File" + let icon = NSWorkspace.shared.icon(forFile: url.path) + + var thumbnail: NSImage? + if !isFolder, let size, size <= Self.thumbnailByteLimit { + thumbnail = await quickLookThumbnail(url) + } + return SharePreviewItem( + name: url.lastPathComponent, + detail: isFolder ? "Folder" : [kind, size.map(Self.format)].compactMap { $0 }.joined(separator: " · "), + icon: icon, + thumbnail: thumbnail, + byteCount: isFolder ? nil : size, + isFolder: isFolder + ) + } + + private func imagePreview(_ provider: NSItemProvider, typeIdentifier: String) async -> SharePreviewItem? { + guard let data = try? await loadData(provider, typeIdentifier: typeIdentifier) else { return nil } + let size = Int64(data.count) + let type = UTType(typeIdentifier) + return SharePreviewItem( + name: provider.suggestedName ?? "Shared Image", + detail: [type?.localizedDescription ?? "Image", Self.format(size)].joined(separator: " · "), + icon: type.map { NSWorkspace.shared.icon(for: $0) } ?? NSImage(), + thumbnail: size <= Self.thumbnailByteLimit ? downsampled(data) : nil, + byteCount: size, + isFolder: false + ) + } + + private func linkPreview(_ url: URL) -> SharePreviewItem { + SharePreviewItem( + name: url.host ?? url.absoluteString, + detail: url.absoluteString, + icon: NSImage(systemSymbolName: "link", accessibilityDescription: nil) ?? NSImage(), + thumbnail: nil, + byteCount: Int64(url.absoluteString.utf8.count), + isFolder: false + ) + } + + private func textPreview(_ text: String, name: String) -> SharePreviewItem { + let snippet = text.trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: "\n", with: " ") + return SharePreviewItem( + name: name, + detail: String(snippet.prefix(120)), + icon: NSImage(systemSymbolName: "text.alignleft", accessibilityDescription: nil) ?? NSImage(), + thumbnail: nil, + byteCount: Int64(text.utf8.count), + isFolder: false + ) + } + + private func quickLookThumbnail(_ url: URL) async -> NSImage? { + let request = QLThumbnailGenerator.Request( + fileAt: url, + size: thumbnailSize, + scale: NSScreen.main?.backingScaleFactor ?? 2, + representationTypes: .thumbnail + ) + return try? await QLThumbnailGenerator.shared.generateBestRepresentation(for: request).nsImage + } + + private func downsampled(_ data: Data) -> NSImage? { + guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { return nil } + let options: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: thumbnailSize.width * 2, + ] + guard let image = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else { return nil } + return NSImage(cgImage: image, size: .zero) + } + + private func loadURL(_ provider: NSItemProvider) async throws -> URL { + try await withCheckedThrowingContinuation { continuation in + _ = provider.loadObject(ofClass: NSURL.self) { url, error in + if let error { + continuation.resume(throwing: error) + } else if let url = url as? NSURL { + continuation.resume(returning: url as URL) + } else { + continuation.resume(throwing: CocoaError(.fileReadUnknown)) + } + } + } + } + + private func loadData(_ provider: NSItemProvider, typeIdentifier: String) async throws -> Data { + try await withCheckedThrowingContinuation { continuation in + _ = provider.loadDataRepresentation(forTypeIdentifier: typeIdentifier) { data, error in + if let error { + continuation.resume(throwing: error) + } else if let data { + continuation.resume(returning: data) + } else { + continuation.resume(throwing: CocoaError(.fileReadUnknown)) + } + } + } + } + + nonisolated static func format(_ bytes: Int64) -> String { + ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file) + } +} diff --git a/AirSyncShareExtension/ShareView.swift b/AirSyncShareExtension/ShareView.swift new file mode 100644 index 00000000..a4e4e2f6 --- /dev/null +++ b/AirSyncShareExtension/ShareView.swift @@ -0,0 +1,159 @@ +import AppKit +import SwiftUI + +@MainActor +final class ShareModel: ObservableObject { + enum Phase: Equatable { + case working(String) + case failed(String) + } + + @Published var items: [SharePreviewItem] = [] + @Published var isLoadingPreview = true + @Published var phase: Phase = .working("Preparing…") + + var listHeight: CGFloat { + let rows = CGFloat(min(items.count, 3)) + return rows * 60 + max(rows - 1, 0) * 8 + } + + var totalHeight: CGFloat { + let base: CGFloat = 215 + return items.isEmpty ? base : base + listHeight + 14 + } + + var summary: String { + if isLoadingPreview { return "Checking items…" } + guard !items.isEmpty else { return "Nothing to send" } + let total = items.compactMap(\.byteCount).reduce(0, +) + let count = items.count == 1 ? "1 item" : "\(items.count) items" + return total > 0 ? "\(count) · \(SharePreviewLoader.format(total))" : count + } +} + +struct ShareView: View { + @ObservedObject var model: ShareModel + let cancel: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + header + if !model.items.isEmpty { itemList } + status + buttons + } + .padding(20) + .frame(width: 380, height: model.totalHeight, alignment: .topLeading) + } + + private var header: some View { + VStack(alignment: .leading, spacing: 3) { + Text("Send with AirSync") + .font(.title3.weight(.semibold)) + Text(model.summary) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + + private var itemList: some View { + ScrollView { + VStack(spacing: 8) { + ForEach(model.items) { item in + ShareItemRow(item: item) + } + } + } + .frame(height: model.listHeight) + } + + @ViewBuilder + private var status: some View { + switch model.phase { + case .working(let message): + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text(message) + } + .font(.callout) + .foregroundStyle(.secondary) + case .failed(let message): + Label(message, systemImage: "exclamationmark.triangle.fill") + .font(.callout) + .foregroundStyle(.red) + } + } + + private var buttons: some View { + HStack { + Spacer() + Button(isFailed ? "Close" : "Cancel", action: cancel) + .keyboardShortcut(.cancelAction) + .modifier(GlassButtonStyle(prominent: false)) + } + .controlSize(.large) + } + + private var isFailed: Bool { + if case .failed = model.phase { return true } + return false + } +} + +private struct ShareItemRow: View { + let item: SharePreviewItem + + var body: some View { + HStack(spacing: 12) { + Group { + if let thumbnail = item.thumbnail { + Image(nsImage: thumbnail).resizable().scaledToFill() + } else { + Image(nsImage: item.icon).resizable().scaledToFit().padding(6) + } + } + .frame(width: 44, height: 44) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + + VStack(alignment: .leading, spacing: 2) { + Text(item.name).lineLimit(1).truncationMode(.middle) + Text(item.detail) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + Spacer(minLength: 0) + } + .padding(8) + .modifier(GlassCard()) + } +} + +private struct GlassCard: ViewModifier { + func body(content: Content) -> some View { + if #available(macOS 26, *) { + content.glassEffect(.regular, in: .rect(cornerRadius: 14)) + } else { + content.background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + } +} + +private struct GlassButtonStyle: ViewModifier { + let prominent: Bool + + func body(content: Content) -> some View { + if #available(macOS 26, *) { + if prominent { + content.buttonStyle(.glassProminent) + } else { + content.buttonStyle(.glass) + } + } else if prominent { + content.buttonStyle(.borderedProminent) + } else { + content.buttonStyle(.bordered) + } + } +} diff --git a/AirSyncShareExtension/ShareViewController.swift b/AirSyncShareExtension/ShareViewController.swift index 4f2085b1..e315915d 100644 --- a/AirSyncShareExtension/ShareViewController.swift +++ b/AirSyncShareExtension/ShareViewController.swift @@ -1,14 +1,13 @@ import AppKit +import SwiftUI final class ShareViewController: NSViewController { - private lazy var detail = NSTextField(wrappingLabelWithString: "AirSync will send this to your connected device or let you pick one.") - private lazy var sendButton = NSButton(title: "Send with AirSync", target: self, action: #selector(send)) - private lazy var cancelButton = NSButton(title: "Cancel", target: self, action: #selector(cancel)) + private let model = ShareModel() private var sendTask: Task? init() { super.init(nibName: nil, bundle: nil) - preferredContentSize = NSSize(width: 360, height: 150) + preferredContentSize = NSSize(width: 380, height: 215) } @available(*, unavailable) @@ -17,36 +16,56 @@ final class ShareViewController: NSViewController { } override func loadView() { - let title = NSTextField(wrappingLabelWithString: "Send with AirSync") - title.font = .systemFont(ofSize: 17, weight: .semibold) - detail.textColor = .secondaryLabelColor - sendButton.keyEquivalent = "\r" - cancelButton.keyEquivalent = "\u{1b}" - - let buttons = NSStackView(views: [sendButton, cancelButton]) - buttons.orientation = .horizontal - buttons.spacing = 8 + let host = NSHostingView(rootView: ShareView( + model: model, + cancel: { [weak self] in self?.cancel() } + )) + host.translatesAutoresizingMaskIntoConstraints = false + let container = NSView(frame: NSRect(origin: .zero, size: preferredContentSize)) + if #available(macOS 26, *) { + let glass = NSGlassEffectView() + glass.contentView = host + glass.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(glass) + NSLayoutConstraint.activate([ + glass.topAnchor.constraint(equalTo: container.topAnchor), + glass.bottomAnchor.constraint(equalTo: container.bottomAnchor), + glass.leadingAnchor.constraint(equalTo: container.leadingAnchor), + glass.trailingAnchor.constraint(equalTo: container.trailingAnchor), + ]) + } else { + container.addSubview(host) + NSLayoutConstraint.activate([ + host.topAnchor.constraint(equalTo: container.topAnchor), + host.bottomAnchor.constraint(equalTo: container.bottomAnchor), + host.leadingAnchor.constraint(equalTo: container.leadingAnchor), + host.trailingAnchor.constraint(equalTo: container.trailingAnchor), + ]) + } + view = container + } - let stack = NSStackView(views: [title, detail, buttons]) - stack.orientation = .vertical - stack.alignment = .leading - stack.spacing = 12 - stack.translatesAutoresizingMaskIntoConstraints = false + override func viewDidLoad() { + super.viewDidLoad() + guard let items = extensionContext?.inputItems as? [NSExtensionItem] else { + model.isLoadingPreview = false + return + } + send() + Task { @MainActor in + model.items = await SharePreviewLoader().load(items) + model.isLoadingPreview = false + resizeToFit() + } + } - let content = NSView(frame: NSRect(origin: .zero, size: preferredContentSize)) - content.addSubview(stack) - NSLayoutConstraint.activate([ - stack.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20), - stack.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -20), - stack.centerYAnchor.constraint(equalTo: content.centerYAnchor), - ]) - view = content + private func resizeToFit() { + preferredContentSize = NSSize(width: 380, height: model.totalHeight) } - @objc private func send() { + private func send() { guard sendTask == nil else { return } - sendButton.isEnabled = false - detail.stringValue = "Preparing this share for AirSync..." + model.phase = .working("Preparing this share for AirSync…") sendTask = Task { @MainActor in await handOff() } @@ -66,7 +85,7 @@ final class ShareViewController: NSViewController { do { let urls = try await loader.prepare(items) try Task.checkCancellation() - detail.stringValue = "Opening AirSync..." + model.phase = .working("Opening AirSync…") let appURL = Bundle.main.bundleURL .deletingLastPathComponent() .deletingLastPathComponent() @@ -90,14 +109,11 @@ final class ShareViewController: NSViewController { } private func showError(_ message: String) { - detail.stringValue = message - sendButton.isEnabled = true - cancelButton.isEnabled = true + model.phase = .failed(message) sendTask = nil } - @objc private func cancel() { - guard cancelButton.isEnabled else { return } + private func cancel() { sendTask?.cancel() extensionContext?.cancelRequest( withError: NSError(domain: NSCocoaErrorDomain, code: NSUserCancelledError) From b49cb305d53e855d918da9242d72a5b9f500af1b Mon Sep 17 00:00:00 2001 From: sameerasw Date: Tue, 13 Oct 2026 22:57:04 +0530 Subject: [PATCH 3/4] Clean up staged share files after transfer and harden the Share extension Co-Authored-By: Claude Sonnet 5.5 --- AirSyncShareExtension/ShareViewController.swift | 4 ++++ README.md | 2 +- airsync-mac/Core/QuickShare/QuickShareManager.swift | 9 +++++++++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/AirSyncShareExtension/ShareViewController.swift b/AirSyncShareExtension/ShareViewController.swift index e315915d..1abf8d17 100644 --- a/AirSyncShareExtension/ShareViewController.swift +++ b/AirSyncShareExtension/ShareViewController.swift @@ -72,6 +72,7 @@ final class ShareViewController: NSViewController { } private func handOff() async { + defer { sendTask = nil } guard let items = extensionContext?.inputItems as? [NSExtensionItem], let group = FileManager.default.containerURL( forSecurityApplicationGroupIdentifier: "group.sameerasw.airsync-mac.widget" @@ -90,6 +91,9 @@ final class ShareViewController: NSViewController { .deletingLastPathComponent() .deletingLastPathComponent() .deletingLastPathComponent() + guard appURL.pathExtension == "app" else { + throw CocoaError(.fileNoSuchFile) + } let configuration = NSWorkspace.OpenConfiguration() configuration.allowsRunningApplicationSubstitution = false openingStarted = true diff --git a/README.md b/README.md index bf94078a..66a7fa00 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ In XCode, select the `AirSync Self Compiled` Scheme. Then at the menu bar, selec ## Share menu The macOS Share extension hands files to AirSync's existing Quick Share transfer. It uses the connected device when one is available, or opens device selection otherwise. Images and videos supplied as temporary data are copied to the existing app group's cache first. Text and web links are sent as text files. Cached copies older than 24 hours are removed on a later share. -The current file sender does not support folders. macOS and each sharing app control menu placement, so AirSync cannot force a position above AirDrop or appear for every item. +The current file sender does not support folders. Enable the extension in System Settings → Extensions → Sharing. macOS and each sharing app control where AirSync appears in the Share menu, so its position cannot be forced. For release signing, enable the existing `group.sameerasw.airsync-mac.widget` app group for the new Share extension identifier in the Apple Developer account. Sign the app and its extensions with the same team. diff --git a/airsync-mac/Core/QuickShare/QuickShareManager.swift b/airsync-mac/Core/QuickShare/QuickShareManager.swift index 2ad2de02..bfb14493 100644 --- a/airsync-mac/Core/QuickShare/QuickShareManager.swift +++ b/airsync-mac/Core/QuickShare/QuickShareManager.swift @@ -126,7 +126,14 @@ public class QuickShareManager: NSObject, ObservableObject, MainAppDelegate, Sha } } + private func removeStagedShareFiles() { + for url in transferURLs where url.pathComponents.contains("AirSyncShares") { + try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) + } + } + public func cancelActiveTransfer() { + removeStagedShareFiles() switch transferState { case .connecting(let id), .awaitingPin(_, let id), .sending(let id): NearbyConnectionManager.shared.cancelOutgoingTransfer(id: id) @@ -333,6 +340,7 @@ public class QuickShareManager: NSObject, ObservableObject, MainAppDelegate, Sha public func connectionFailed(with error: Error) { transferState = .failed(error.localizedDescription) + removeStagedShareFiles() DispatchQueue.main.asyncAfter(deadline: .now() + 3) { AppState.shared.showingQuickShareTransfer = false self.transferState = .idle @@ -351,6 +359,7 @@ public class QuickShareManager: NSObject, ObservableObject, MainAppDelegate, Sha public func transferFinished() { transferState = .finished + removeStagedShareFiles() DispatchQueue.main.asyncAfter(deadline: .now() + 2) { AppState.shared.showingQuickShareTransfer = false self.transferState = .idle From 39590363df7ee5c725975c26875af14ba172d79c Mon Sep 17 00:00:00 2001 From: sameerasw Date: Tue, 27 Oct 2026 18:55:58 +0530 Subject: [PATCH 4/4] fix: re-opening app on share extension --- AirSync.xcodeproj/project.pbxproj | 200 +++++++++++------- .../ShareViewController.swift | 14 +- 2 files changed, 132 insertions(+), 82 deletions(-) diff --git a/AirSync.xcodeproj/project.pbxproj b/AirSync.xcodeproj/project.pbxproj index d429f8f1..92d7e0de 100644 --- a/AirSync.xcodeproj/project.pbxproj +++ b/AirSync.xcodeproj/project.pbxproj @@ -408,85 +408,6 @@ /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ - A1700000000000000000000C /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - APPLICATION_EXTENSION_API_ONLY = YES; - CODE_SIGN_ENTITLEMENTS = AirSyncShareExtension.entitlements; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 32; - DEVELOPMENT_TEAM = WCB4HTANA6; - ENABLE_APP_SANDBOX = YES; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = AirSyncShareExtension/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = AirSync; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - "@executable_path/../../../../Frameworks", - ); - MACOSX_DEPLOYMENT_TARGET = 14.5; - MARKETING_VERSION = 4.1.0; - PRODUCT_BUNDLE_IDENTIFIER = "sameerasw.airsync-mac.share"; - PRODUCT_NAME = "$(TARGET_NAME)"; - SKIP_INSTALL = YES; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - }; - name = Debug; - }; - A1700000000000000000000D /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - APPLICATION_EXTENSION_API_ONLY = YES; - CODE_SIGN_ENTITLEMENTS = AirSyncShareExtension.entitlements; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 32; - DEVELOPMENT_TEAM = WCB4HTANA6; - ENABLE_APP_SANDBOX = YES; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = AirSyncShareExtension/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = AirSync; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - "@executable_path/../../../../Frameworks", - ); - MACOSX_DEPLOYMENT_TARGET = 14.5; - MARKETING_VERSION = 4.1.0; - PRODUCT_BUNDLE_IDENTIFIER = "sameerasw.airsync-mac.share"; - PRODUCT_NAME = "$(TARGET_NAME)"; - SKIP_INSTALL = YES; - SWIFT_VERSION = 5.0; - }; - name = Release; - }; - A1700000000000000000000E /* Self Compiled */ = { - isa = XCBuildConfiguration; - buildSettings = { - APPLICATION_EXTENSION_API_ONLY = YES; - CODE_SIGN_ENTITLEMENTS = AirSyncShareExtension.entitlements; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 32; - DEVELOPMENT_TEAM = WCB4HTANA6; - ENABLE_APP_SANDBOX = YES; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = AirSyncShareExtension/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = AirSync; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - "@executable_path/../../../../Frameworks", - ); - MACOSX_DEPLOYMENT_TARGET = 14.5; - MARKETING_VERSION = 4.1.0; - PRODUCT_BUNDLE_IDENTIFIER = "sameerasw.airsync-mac.share"; - PRODUCT_NAME = "$(TARGET_NAME)"; - SKIP_INSTALL = YES; - SWIFT_VERSION = 5.0; - }; - name = "Self Compiled"; - }; 11F6093B2EA2A88900AB6BDC /* Self Compiled */ = { isa = XCBuildConfiguration; baseConfigurationReferenceAnchor = B9673B592E35A2A1006D284A /* airsync-mac */; @@ -602,6 +523,127 @@ }; name = "Self Compiled"; }; + A1700000000000000000000C /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + AUTOMATION_APPLE_EVENTS = NO; + CODE_SIGN_ENTITLEMENTS = AirSyncShareExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 32; + DEVELOPMENT_TEAM = WCB4HTANA6; + ENABLE_APP_SANDBOX = YES; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; + ENABLE_RESOURCE_ACCESS_CALENDARS = NO; + ENABLE_RESOURCE_ACCESS_CAMERA = NO; + ENABLE_RESOURCE_ACCESS_CONTACTS = NO; + ENABLE_RESOURCE_ACCESS_LOCATION = NO; + ENABLE_RESOURCE_ACCESS_PHOTO_LIBRARY = NO; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = AirSyncShareExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = AirSync; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../../../../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 14.5; + MARKETING_VERSION = 4.1.0; + PRODUCT_BUNDLE_IDENTIFIER = "sameerasw.airsync-mac.share"; + PRODUCT_NAME = "$(TARGET_NAME)"; + RUNTIME_EXCEPTION_ALLOW_DYLD_ENVIRONMENT_VARIABLES = NO; + RUNTIME_EXCEPTION_ALLOW_JIT = NO; + RUNTIME_EXCEPTION_ALLOW_UNSIGNED_EXECUTABLE_MEMORY = NO; + RUNTIME_EXCEPTION_DEBUGGING_TOOL = NO; + RUNTIME_EXCEPTION_DISABLE_EXECUTABLE_PAGE_PROTECTION = NO; + RUNTIME_EXCEPTION_DISABLE_LIBRARY_VALIDATION = NO; + SKIP_INSTALL = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + A1700000000000000000000D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + AUTOMATION_APPLE_EVENTS = NO; + CODE_SIGN_ENTITLEMENTS = AirSyncShareExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 32; + DEVELOPMENT_TEAM = WCB4HTANA6; + ENABLE_APP_SANDBOX = YES; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; + ENABLE_RESOURCE_ACCESS_CALENDARS = NO; + ENABLE_RESOURCE_ACCESS_CAMERA = NO; + ENABLE_RESOURCE_ACCESS_CONTACTS = NO; + ENABLE_RESOURCE_ACCESS_LOCATION = NO; + ENABLE_RESOURCE_ACCESS_PHOTO_LIBRARY = NO; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = AirSyncShareExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = AirSync; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../../../../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 14.5; + MARKETING_VERSION = 4.1.0; + PRODUCT_BUNDLE_IDENTIFIER = "sameerasw.airsync-mac.share"; + PRODUCT_NAME = "$(TARGET_NAME)"; + RUNTIME_EXCEPTION_ALLOW_DYLD_ENVIRONMENT_VARIABLES = NO; + RUNTIME_EXCEPTION_ALLOW_JIT = NO; + RUNTIME_EXCEPTION_ALLOW_UNSIGNED_EXECUTABLE_MEMORY = NO; + RUNTIME_EXCEPTION_DEBUGGING_TOOL = NO; + RUNTIME_EXCEPTION_DISABLE_EXECUTABLE_PAGE_PROTECTION = NO; + RUNTIME_EXCEPTION_DISABLE_LIBRARY_VALIDATION = NO; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + A1700000000000000000000E /* Self Compiled */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + AUTOMATION_APPLE_EVENTS = NO; + CODE_SIGN_ENTITLEMENTS = AirSyncShareExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 32; + DEVELOPMENT_TEAM = WCB4HTANA6; + ENABLE_APP_SANDBOX = YES; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; + ENABLE_RESOURCE_ACCESS_CALENDARS = NO; + ENABLE_RESOURCE_ACCESS_CAMERA = NO; + ENABLE_RESOURCE_ACCESS_CONTACTS = NO; + ENABLE_RESOURCE_ACCESS_LOCATION = NO; + ENABLE_RESOURCE_ACCESS_PHOTO_LIBRARY = NO; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = AirSyncShareExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = AirSync; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../../../../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 14.5; + MARKETING_VERSION = 4.1.0; + PRODUCT_BUNDLE_IDENTIFIER = "sameerasw.airsync-mac.share"; + PRODUCT_NAME = "$(TARGET_NAME)"; + RUNTIME_EXCEPTION_ALLOW_DYLD_ENVIRONMENT_VARIABLES = NO; + RUNTIME_EXCEPTION_ALLOW_JIT = NO; + RUNTIME_EXCEPTION_ALLOW_UNSIGNED_EXECUTABLE_MEMORY = NO; + RUNTIME_EXCEPTION_DEBUGGING_TOOL = NO; + RUNTIME_EXCEPTION_DISABLE_EXECUTABLE_PAGE_PROTECTION = NO; + RUNTIME_EXCEPTION_DISABLE_LIBRARY_VALIDATION = NO; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + }; + name = "Self Compiled"; + }; B9673B602E35A2A3006D284A /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReferenceAnchor = B9673B592E35A2A1006D284A /* airsync-mac */; diff --git a/AirSyncShareExtension/ShareViewController.swift b/AirSyncShareExtension/ShareViewController.swift index 1abf8d17..30e9f814 100644 --- a/AirSyncShareExtension/ShareViewController.swift +++ b/AirSyncShareExtension/ShareViewController.swift @@ -87,15 +87,16 @@ final class ShareViewController: NSViewController { let urls = try await loader.prepare(items) try Task.checkCancellation() model.phase = .working("Opening AirSync…") - let appURL = Bundle.main.bundleURL + let containingAppURL = Bundle.main.bundleURL .deletingLastPathComponent() .deletingLastPathComponent() .deletingLastPathComponent() - guard appURL.pathExtension == "app" else { + guard containingAppURL.pathExtension == "app" else { throw CocoaError(.fileNoSuchFile) } + let appURL = runningAppURL(matching: containingAppURL) ?? containingAppURL let configuration = NSWorkspace.OpenConfiguration() - configuration.allowsRunningApplicationSubstitution = false + configuration.allowsRunningApplicationSubstitution = true openingStarted = true _ = try await NSWorkspace.shared.open( urls, @@ -112,6 +113,13 @@ final class ShareViewController: NSViewController { } } + private func runningAppURL(matching containingAppURL: URL) -> URL? { + guard let bundleID = Bundle(url: containingAppURL)?.bundleIdentifier else { return nil } + return NSRunningApplication.runningApplications(withBundleIdentifier: bundleID) + .first { !$0.isTerminated }? + .bundleURL + } + private func showError(_ message: String) { model.phase = .failed(message) sendTask = nil