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..7285264e 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,13 @@

+## Installation +- You can install AirSync mac app from either [GitHub releases](https://github.com/sameerasw/airsync-mac/releases/latest) or from [homebrew](https://brew.sh/) from the following command. + + ``` + brew install airsync + ``` + ## Requirements - Min : macOS 14.5 - Opt: scrcpy, adb & media-control installed from brew @@ -19,6 +26,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 } } diff --git a/appcast.xml b/appcast.xml index ecd25755..ba11d14d 100644 --- a/appcast.xml +++ b/appcast.xml @@ -3,12 +3,12 @@ AirSync - 4.0.0 - Fri, 24 Jul 2026 19:31:30 +0530 - 30 - 4.0.0 + 4.0.1 + Thu, 27 Aug 2026 17:45:15 +0530 + 31 + 4.0.1 14.5 - +