diff --git a/AirSync.xcodeproj/project.pbxproj b/AirSync.xcodeproj/project.pbxproj index 92c1d201..228fa847 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 */; @@ -400,7 +482,7 @@ CODE_SIGN_ENTITLEMENTS = AirSync.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 32; + CURRENT_PROJECT_VERSION = 33; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = WCB4HTANA6; ENABLE_APP_SANDBOX = NO; @@ -420,7 +502,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.5; - MARKETING_VERSION = 4.1.0; + MARKETING_VERSION = 4.2.0; PRODUCT_BUNDLE_IDENTIFIER = "sameerasw.airsync-mac"; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -441,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 = 33; + 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.2.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 = 33; + 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.2.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 = 33; + 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.2.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 */; @@ -581,7 +784,7 @@ CODE_SIGN_ENTITLEMENTS = AirSync.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 32; + CURRENT_PROJECT_VERSION = 33; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = WCB4HTANA6; ENABLE_APP_SANDBOX = NO; @@ -601,7 +804,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.5; - MARKETING_VERSION = 4.1.0; + MARKETING_VERSION = 4.2.0; PRODUCT_BUNDLE_IDENTIFIER = "sameerasw.airsync-mac"; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -634,7 +837,7 @@ CODE_SIGN_ENTITLEMENTS = AirSync.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 32; + CURRENT_PROJECT_VERSION = 33; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = WCB4HTANA6; ENABLE_APP_SANDBOX = NO; @@ -654,7 +857,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.5; - MARKETING_VERSION = 4.1.0; + MARKETING_VERSION = 4.2.0; PRODUCT_BUNDLE_IDENTIFIER = "sameerasw.airsync-mac"; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -682,7 +885,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = AirSyncWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 31; + CURRENT_PROJECT_VERSION = 33; DEVELOPMENT_TEAM = WCB4HTANA6; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; @@ -696,7 +899,7 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 27.0; - MARKETING_VERSION = 4.0.1; + MARKETING_VERSION = 4.2.0; PRODUCT_BUNDLE_IDENTIFIER = "sameerasw.airsync-mac.widget"; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -716,7 +919,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = AirSyncWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 31; + CURRENT_PROJECT_VERSION = 33; DEVELOPMENT_TEAM = WCB4HTANA6; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; @@ -730,7 +933,7 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 27.0; - MARKETING_VERSION = 4.0.1; + MARKETING_VERSION = 4.2.0; PRODUCT_BUNDLE_IDENTIFIER = "sameerasw.airsync-mac.widget"; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -750,7 +953,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = AirSyncWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 31; + CURRENT_PROJECT_VERSION = 33; DEVELOPMENT_TEAM = WCB4HTANA6; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; @@ -764,7 +967,7 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 27.0; - MARKETING_VERSION = 4.0.1; + MARKETING_VERSION = 4.2.0; PRODUCT_BUNDLE_IDENTIFIER = "sameerasw.airsync-mac.widget"; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -780,6 +983,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/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 new file mode 100644 index 00000000..30e9f814 --- /dev/null +++ b/AirSyncShareExtension/ShareViewController.swift @@ -0,0 +1,134 @@ +import AppKit +import SwiftUI + +final class ShareViewController: NSViewController { + private let model = ShareModel() + private var sendTask: Task? + + init() { + super.init(nibName: nil, bundle: nil) + preferredContentSize = NSSize(width: 380, height: 215) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError() + } + + override func loadView() { + 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 + } + + 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() + } + } + + private func resizeToFit() { + preferredContentSize = NSSize(width: 380, height: model.totalHeight) + } + + private func send() { + guard sendTask == nil else { return } + model.phase = .working("Preparing this share for AirSync…") + sendTask = Task { @MainActor in + await handOff() + } + } + + 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" + ) 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() + model.phase = .working("Opening AirSync…") + let containingAppURL = Bundle.main.bundleURL + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + guard containingAppURL.pathExtension == "app" else { + throw CocoaError(.fileNoSuchFile) + } + let appURL = runningAppURL(matching: containingAppURL) ?? containingAppURL + let configuration = NSWorkspace.OpenConfiguration() + configuration.allowsRunningApplicationSubstitution = true + 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 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 + } + + private func cancel() { + sendTask?.cancel() + extensionContext?.cancelRequest( + withError: NSError(domain: NSCocoaErrorDomain, code: NSUserCancelledError) + ) + } +} diff --git a/README.md b/README.md index 3e732434..66a7fa00 100644 --- a/README.md +++ b/README.md @@ -10,13 +10,6 @@

-## 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 @@ -26,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. 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. + ## 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/airsync-mac/Core/QuickShare/NearbyConnection.swift b/airsync-mac/Core/QuickShare/NearbyConnection.swift index 041ce34c..644a50ee 100644 --- a/airsync-mac/Core/QuickShare/NearbyConnection.swift +++ b/airsync-mac/Core/QuickShare/NearbyConnection.swift @@ -55,24 +55,59 @@ public class NearbyConnection{ nonisolated deinit {} + private var socketReady=false + + /// Outbound connections fail instead of waiting forever on a stale endpoint. + internal var openTimeout:TimeInterval? { nil } + func start(){ print("[nearby] Starting connection to \(connection.endpoint)") connection.stateUpdateHandler={ [weak self] state in Task { @MainActor in guard let self=self else { return } - if case .ready = state { + switch state { + case .ready: + self.socketReady=true self.connectionReady() self.receiveFrameAsync() - } else if case .failed(let err) = state { - self.lastError=err - print("[nearby] Error opening socket: \(err)") - self.handleConnectionClosure() + case .waiting(let err) where self.openTimeout != nil && !self.socketReady: + self.failToOpen(err) + case .failed(let err): + if self.socketReady { + self.lastError=err + print("[nearby] Socket failed: \(err)") + self.handleConnectionClosure() + } else { + self.failToOpen(err) + } + default: + break } } } connection.start(queue: NearbyConnection.dispatchQueue) + if let timeout=openTimeout { + Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) + guard let self=self, !self.socketReady else { return } + self.failToOpen(NWError.posix(.ETIMEDOUT)) + } + } } - + + private func failToOpen(_ error:Error){ + guard !connectionClosed else { return } + connectionClosed=true + lastError=error + print("[nearby] Error opening socket: \(error)") + connection.cancel() + connectionDidFailToOpen() + } + + internal func connectionDidFailToOpen(){ + handleConnectionClosure() + } + func connectionReady(){} internal func handleConnectionClosure(){ diff --git a/airsync-mac/Core/QuickShare/NearbyConnectionManager.swift b/airsync-mac/Core/QuickShare/NearbyConnectionManager.swift index 31443def..b3ea5bbd 100644 --- a/airsync-mac/Core/QuickShare/NearbyConnectionManager.swift +++ b/airsync-mac/Core/QuickShare/NearbyConnectionManager.swift @@ -470,7 +470,10 @@ public class NearbyConnectionManager : NSObject, NetServiceDelegate, InboundNear } public func startOutgoingTransfer(deviceID:String, delegate:ShareExtensionDelegate, urls:[URL]){ - guard let info=foundServices[deviceID] else {return} + guard let info=foundServices[deviceID] else { + delegate.connectionFailed(with: NearbyError.protocolError("Device is no longer available")) + return + } let tcp=NWProtocolTCP.Options.init() tcp.noDelay=true let nwconn=NWConnection(to: info.service.endpoint, using: NWParameters(tls: .none, tcp: tcp)) diff --git a/airsync-mac/Core/QuickShare/OutboundNearbyConnection.swift b/airsync-mac/Core/QuickShare/OutboundNearbyConnection.swift index 2f0ba01f..5d1d7b81 100644 --- a/airsync-mac/Core/QuickShare/OutboundNearbyConnection.swift +++ b/airsync-mac/Core/QuickShare/OutboundNearbyConnection.swift @@ -66,6 +66,12 @@ public class OutboundNearbyConnection:NearbyConnection{ try? sendDisconnectionAndDisconnect() } + override var openTimeout:TimeInterval? { 5 } + + override func connectionDidFailToOpen() { + delegate?.outboundConnection(connection: self, failedWithError: lastError ?? NearbyError.inputOutput) + } + override func connectionReady() { super.connectionReady() do{ diff --git a/airsync-mac/Core/QuickShare/QuickShareManager.swift b/airsync-mac/Core/QuickShare/QuickShareManager.swift index 2ad2de02..68297471 100644 --- a/airsync-mac/Core/QuickShare/QuickShareManager.swift +++ b/airsync-mac/Core/QuickShare/QuickShareManager.swift @@ -49,6 +49,16 @@ public class QuickShareManager: NSObject, ObservableObject, MainAppDelegate, Sha } private var activeIncomingTransfers: [String: QuickShareTransferInfo] = [:] + + // Android advertises a fresh endpoint each session and stale mDNS records can linger, + // so one phone may show up under several endpoint IDs. Newest is last. + private var endpointsByName: [String: [RemoteDeviceInfo]] = [:] + private var sendTargetName: String? + private var triedEndpointIDs: Set = [] + private var autoTargetTask: Task? + // Kept across sends: a stale record keeps getting replayed from the mDNS cache until it expires + private var lastGoodEndpointByName: [String: String] = [:] + private var unreachableEndpointIDs: Set = [] override private init() { super.init() @@ -103,6 +113,10 @@ public class QuickShareManager: NSObject, ObservableObject, MainAppDelegate, Sha public func startDiscovery(autoTargetName: String? = nil) { discoveredDevices.removeAll() + endpointsByName.removeAll() + resetSendTarget() + autoTargetTask?.cancel() + autoTargetTask = nil self.autoTargetDeviceName = autoTargetName transferState = .discovering NearbyConnectionManager.shared.addShareExtensionDelegate(self) @@ -116,6 +130,10 @@ public class QuickShareManager: NSObject, ObservableObject, MainAppDelegate, Sha NearbyConnectionManager.shared.stopDeviceDiscovery() NearbyConnectionManager.shared.removeShareExtensionDelegate(self) discoveredDevices.removeAll() + endpointsByName.removeAll() + resetSendTarget() + autoTargetTask?.cancel() + autoTargetTask = nil self.autoTargetDeviceName = nil switch transferState { @@ -126,7 +144,15 @@ 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() { + resetSendTarget() + removeStagedShareFiles() switch transferState { case .connecting(let id), .awaitingPin(_, let id), .sending(let id): NearbyConnectionManager.shared.cancelOutgoingTransfer(id: id) @@ -149,18 +175,46 @@ public class QuickShareManager: NSObject, ObservableObject, MainAppDelegate, Sha } public func sendFiles(urls: [URL], to device: RemoteDeviceInfo) { - guard let deviceID = device.id else { return } - transferState = .connecting(deviceID) + autoTargetTask?.cancel() + autoTargetTask = nil + sendTargetName = device.name + triedEndpointIDs = [] + transferURLs = urls transferProgress = 0 - - // Trigger Quick Share mode on Android if it's the connected device - if let connectedDevice = AppState.shared.device, - connectedDevice.name == device.name { - print("[quickshare] Target device matches connected device, sending WebSocket trigger") - WebSocketServer.shared.sendQuickShareTrigger() + connect(to: device) + } + + private func resetSendTarget() { + sendTargetName = nil + triedEndpointIDs = [] + } + + /// Endpoints can appear mid-attempt (the phone re-advertises after our trigger), so look them up fresh. + private func nextUntriedEndpoint() -> RemoteDeviceInfo? { + guard let name = sendTargetName else { return nil } + let untried = (endpointsByName[name] ?? []).filter { $0.id.map { !triedEndpointIDs.contains($0) } ?? false } + return preferredEndpoint(in: untried, named: name) ?? untried.last + } + + /// The endpoint that last worked, else the newest one not known to be dead. + private func preferredEndpoint(in endpoints: [RemoteDeviceInfo], named name: String) -> RemoteDeviceInfo? { + if let good = lastGoodEndpointByName[name], let match = endpoints.first(where: { $0.id == good }) { + return match } - - NearbyConnectionManager.shared.startOutgoingTransfer(deviceID: deviceID, delegate: self, urls: urls) + return endpoints.last { $0.id.map { !unreachableEndpointIDs.contains($0) } ?? false } + } + + private func bestEndpoint(named name: String) -> RemoteDeviceInfo? { + let endpoints = endpointsByName[name] ?? [] + return preferredEndpoint(in: endpoints, named: name) ?? endpoints.last + } + + private func connect(to device: RemoteDeviceInfo) { + guard let deviceID = device.id else { return } + triedEndpointIDs.insert(deviceID) + print("[quickshare] Connecting to '\(device.name)' via endpoint \(deviceID)") + transferState = .connecting(deviceID) + NearbyConnectionManager.shared.startOutgoingTransfer(deviceID: deviceID, delegate: self, urls: transferURLs) } public func generateQrCodeKey() -> String { @@ -296,20 +350,54 @@ public class QuickShareManager: NSObject, ObservableObject, MainAppDelegate, Sha // MARK: - ShareExtensionDelegate (Outgoing) public func addDevice(device: RemoteDeviceInfo) { - if !discoveredDevices.contains(where: { $0.id == device.id }) { - discoveredDevices.append(device) - - // If auto-targeting is active and name matches, start transfer - if let targetName = autoTargetDeviceName, device.name == targetName { - print("[quickshare] Auto-targeting found device '\(device.name)', starting transfer") - self.autoTargetDeviceName = nil // Clear so it doesn't trigger again - sendFiles(urls: self.transferURLs, to: device) + var endpoints = endpointsByName[device.name] ?? [] + endpoints.removeAll { $0.id == device.id } + endpoints.append(device) + endpointsByName[device.name] = endpoints + showBestEndpoint(named: device.name) + + // Wait briefly so a stale cached record doesn't win over the live one + if let targetName = autoTargetDeviceName, device.name == targetName, autoTargetTask == nil { + print("[quickshare] Auto-targeting found device '\(device.name)', starting transfer") + autoTargetTask = Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: 1_200_000_000) + // If only known-dead records are visible so far, give the live one a few seconds to show up + var waited = 0 + while let self, !Task.isCancelled, waited < 16, + self.preferredEndpoint(in: self.endpointsByName[targetName] ?? [], named: targetName) == nil { + try? await Task.sleep(nanoseconds: 300_000_000) + waited += 1 + } + guard let self, !Task.isCancelled, self.transferState == .discovering, + let target = self.bestEndpoint(named: targetName) else { return } + self.autoTargetDeviceName = nil + self.autoTargetTask = nil + self.sendFiles(urls: self.transferURLs, to: target) } } } - + public func removeDevice(id: String) { - discoveredDevices.removeAll(where: { $0.id == id }) + guard let name = endpointsByName.first(where: { $0.value.contains { $0.id == id } })?.key else { return } + endpointsByName[name]?.removeAll { $0.id == id } + if endpointsByName[name]?.isEmpty == true { + endpointsByName[name] = nil + } + showBestEndpoint(named: name) + } + + private func showBestEndpoint(named name: String) { + let index = discoveredDevices.firstIndex { $0.name == name } + switch (index, bestEndpoint(named: name)) { + case let (index?, newest?): + discoveredDevices[index] = newest + case let (nil, newest?): + discoveredDevices.append(newest) + case let (index?, nil): + discoveredDevices.remove(at: index) + case (nil, nil): + break + } } public func startTransferWithQrCode(device: RemoteDeviceInfo) { @@ -318,6 +406,10 @@ public class QuickShareManager: NSObject, ObservableObject, MainAppDelegate, Sha public func connectionWasEstablished(pinCode: String) { let deviceID = caseDiscoveryID() ?? "unknown" + if let name = sendTargetName { + lastGoodEndpointByName[name] = deviceID + unreachableEndpointIDs.remove(deviceID) + } lastPinCode = pinCode transferState = .awaitingPin(pinCode, deviceID) } @@ -332,7 +424,21 @@ public class QuickShareManager: NSObject, ObservableObject, MainAppDelegate, Sha } public func connectionFailed(with error: Error) { + if case .connecting(let failedID) = transferState { + unreachableEndpointIDs.insert(failedID) + if let name = sendTargetName, lastGoodEndpointByName[name] == failedID { + lastGoodEndpointByName[name] = nil + } + showBestEndpoint(named: sendTargetName ?? "") + } + if case .connecting(let failedID) = transferState, let next = nextUntriedEndpoint() { + print("[quickshare] Endpoint \(failedID) unreachable, trying \(next.id ?? "?")") + connect(to: next) + return + } + resetSendTarget() transferState = .failed(error.localizedDescription) + removeStagedShareFiles() DispatchQueue.main.asyncAfter(deadline: .now() + 3) { AppState.shared.showingQuickShareTransfer = false self.transferState = .idle @@ -351,6 +457,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 diff --git a/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift b/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift index c7421878..4f32f33a 100644 --- a/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift +++ b/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift @@ -68,7 +68,7 @@ extension WebSocketServer { handleBrowseData(message) case .userDisconnected: handleUserDisconnected() - case .volumeControl, .macVolume, .toggleAppNotif, .browseLs, .wakeUpRequest, .macMediaControlResponse, .macInfo, .callControl: + case .volumeControl, .macVolume, .toggleAppNotif, .browseLs, .wakeUpRequest, .macMediaControlResponse, .macInfo, .callControl, .pong: // Outgoing or unexpected messages break } diff --git a/airsync-mac/Core/WebSocket/WebSocketServer.swift b/airsync-mac/Core/WebSocket/WebSocketServer.swift index 0d8a67d9..28f81eb3 100644 --- a/airsync-mac/Core/WebSocket/WebSocketServer.swift +++ b/airsync-mac/Core/WebSocket/WebSocketServer.swift @@ -185,6 +185,7 @@ class WebSocketServer: ObservableObject { do { let message = try self.jsonDecoder.decode(Message.self, from: data) + if message.type == .pong { return } if message.type == .status { // Pong / keepalive check if let dict = message.data.value as? [String: Any], diff --git a/airsync-mac/Localization/fr.json b/airsync-mac/Localization/fr.json index d795e937..e8581ffa 100644 --- a/airsync-mac/Localization/fr.json +++ b/airsync-mac/Localization/fr.json @@ -1,11 +1,11 @@ { "app.name": "AirSync", "menu.mirror": "Miroir", - "menu.browseFiles": "Browse Files", + "menu.browseFiles": "Parcourir les fichiers", "menu.about": "À propos de Airsync", "menu.checkUpdates": "Recherche de mise à jour…", "menu.quit": "Quitter", - "button.connect": "Connect", + "button.connect": "Connecter", "home.title": "AirSync", "home.sidebar.ready": "Prêt", "onboarding.title": "Bienvenue sur AirSync", @@ -18,40 +18,40 @@ "apps.tab": "Applis", "transfers.tab": "Transferts", "settings.tab": "Paramètres", - "settings.myMac": "My Mac", - "settings.sync": "Sync", - "settings.notifications": "Notifications & Alerts", - "settings.notifications.apps": "Apps", + "settings.myMac": "Mon Mac", + "settings.sync": "Synchronisation", + "settings.notifications": "Notifications et alertes", + "settings.notifications.apps": "Applications", "settings.notifications.sync": "Notifications Sync", - "settings.notifications.dismiss": "Sync notification dismissals", - "settings.notifications.system": "System Notifications", - "settings.notifications.sound.default": "Default", - "settings.notifications.grant": "Grant Permission", - "settings.notifications.ai.title": "Apple Intelligence", - "settings.notifications.ai.disableAll": "Disable all AI features", - "settings.notifications.ai.showToolbarButton": "Summarize notifications button in toolbar", - "settings.notifications.ai.includeSilent": "Include silent notifications", - "settings.notifications.ai.menubarSummary": "Menubar pop-up summary", - "settings.notifications.ai.autoMenubarSummary": "Automatic menubar summary", - "settings.notifications.ai.unavailable.title": "Apple Intelligence Unavailable", - "settings.notifications.ai.unavailable.body": "These features require macOS 26.0 or later to run local language models.", - "settings.notifications.calls": "Call Alerts", - "settings.notifications.callAlert": "Call alert", - "settings.notifications.ring": "Ring for calls", - "settings.notifications.apps.connect": "Connect your Android to see the list of apps", - "settings.notifications.apps.none": "No apps found", - "settings.notifications.app.settings": "%@ Notification Settings", - "settings.notifications.app.noSettings": "No settings yet", - "settings.notifications.app.title": "App notifications", - "settings.notifications.app.priority": "Priority", - "settings.notifications.app.priority.alert": "Alert", - "settings.notifications.app.priority.silent": "Silent", - "settings.notifications.close": "Close", - "settings.notifications.mediaPlayback": "Media Playback", - "settings.mirroring": "Mirroring", - "settings.menubar": "Menubar", - "settings.appleIntelligence": "Apple Intelligence", - "settings.appearance": "Appearance & more", + "settings.notifications.dismiss": "Synchroniser les rejets de notification", + "settings.notifications.system": "Notifications système", + "settings.notifications.sound.default": "Défaut", + "settings.notifications.grant": "Accorder la permission", + "settings.notifications.ai.title": "Intelligence Apple", + "settings.notifications.ai.disableAll": "Désactiver toutes les fonctionnalités AI", + "settings.notifications.ai.showToolbarButton": "Résumer les notifications dans la barre d'outils", + "settings.notifications.ai.includeSilent": "Inclure les notifications silencieuses", + "settings.notifications.ai.menubarSummary": "Récapitulatif de la barre de menu", + "settings.notifications.ai.autoMenubarSummary": "Résumé automatique de la barre de menu", + "settings.notifications.ai.unavailable.title": "Intelligence Apple indisponible", + "settings.notifications.ai.unavailable.body": "Ces fonctionnalités nécessitent macOS 26.0 ou ultérieur pour exécuter des modèles de langue locale.", + "settings.notifications.calls": "Alertes d'appel", + "settings.notifications.callAlert": "Alertes d'appel", + "settings.notifications.ring": "Sonner les appels", + "settings.notifications.apps.connect": "Connectez votre Android pour voir la liste des applications", + "settings.notifications.apps.none": "Aucune application trouvée", + "settings.notifications.app.settings": "Paramètres de notification %@", + "settings.notifications.app.noSettings": "Aucun paramètre pour l'instant.", + "settings.notifications.app.title": "Notifications d'applications", + "settings.notifications.app.priority": "Priorité", + "settings.notifications.app.priority.alert": "Alerte", + "settings.notifications.app.priority.silent": "Silencieuse", + "settings.notifications.close": "Fermer", + "settings.notifications.mediaPlayback": "Lecture multimédia", + "settings.mirroring": "Miroir", + "settings.menubar": "Barre de menu", + "settings.appleIntelligence": "Intelligence Apple", + "settings.appearance": "Apparence & plus", "settings.airsyncPlus": "AirSync+", "qr.tab": "Scanner le QR code", "transfers.empty": "Rien de transféré pour l'instant", @@ -64,7 +64,7 @@ "media.play": "Jouer", "media.pause": "Pause", "settings.wiredAdb": "Support d'ADB filaire", - "settings.alwaysKillAdbBeforeConnect": "Always kill-server before connect", + "settings.alwaysKillAdbBeforeConnect": "Toujours tuer le serveur avant la connexion", "quickshare.title": "Quick Share", "quickshare.button": "Quick Share", "quickshare.settings.title": "Paramètres de Quick Share", @@ -77,11 +77,11 @@ "quickshare.error.crypto": "Erreur de chiffrement", "quickshare.transferError": "Erreur du transfert avec %@", "quickshare.settings.autoAccept": "Accepter automatiquement de mon appareil", - "quickshare.settings.popupSharedImages": "Shared file pop-ups", - "quickshare.settings.maxPopups": "Max pop-up cards", - "quickshare.settings.popupSide": "Pop up position", - "quickshare.settings.side.left": "Left", - "quickshare.settings.side.right": "Right", + "quickshare.settings.popupSharedImages": "Popups de fichiers partagés", + "quickshare.settings.maxPopups": "Nombre maximum de cartes pop-up", + "quickshare.settings.popupSide": "Position du pop-up", + "quickshare.settings.side.left": "Gauche", + "quickshare.settings.side.right": "Droite", "quickshare.waiting_for": "En attente de %1$@...", "quickshare.select_device": "Sélectionnez un appareil", "quickshare.searching": "Recherche d'appareils à proximité...", @@ -90,7 +90,7 @@ "quickshare.done": "Confirmer", "quickshare.sending": "Envoi...", "quickshare.receiving": "Réception...", - "quickshare.connecting": "Connecting...", + "quickshare.connecting": "Connexion...", "quickshare.confirm_pin": "Confirmez le PIN sur votre appareil", "quickshare.finished": "Transfert terminé !", "quickshare.failed": "Transfert échoué", @@ -99,12 +99,12 @@ "quickshare.n_files": "%d fichiers", "quickshare.drop.send_to": "Envoyer à %@", "quickshare.drop.pick_device": "Choisissez un appareil auquel envoyer", - "settings.quickshare": "Quick Share and Files", - "settings.fileAccess.title": "File Access", - "settings.fileAccess.enabled": "Enable file access", - "settings.fileAccess.description": "Mount your Android device storage as a local drive in macOS Finder automatically.", - "settings.mirroring.scrcpy.title": "scrcpy Mirror", - "settings.mirroring.scrcpy.description": "scrcpy is a reliable integration built-in to AirSync", + "settings.quickshare": "Partage rapide et fichiers", + "settings.fileAccess.title": "Accès au fichier", + "settings.fileAccess.enabled": "Activer l'accès aux fichiers", + "settings.fileAccess.description": "Montez automatiquement le stockage de votre appareil Android en tant que lecteur local dans le moteur de recherche macOS.", + "settings.mirroring.scrcpy.title": "Miroir scrcpy", + "settings.mirroring.scrcpy.description": "scrcpy est une intégration fiable intégrée à AirSync", "settings.mirroring.native.title": "Native Mirror (BETA)", "settings.mirroring.native.description": "As close as you can get to iPhone Mirroring XD. Powered by scrcpy. WIP", "settings.mirroring.defaultMode": "Android Mirror Mode", diff --git a/airsync-mac/Localization/it.json b/airsync-mac/Localization/it.json index cd85fab8..027ed677 100644 --- a/airsync-mac/Localization/it.json +++ b/airsync-mac/Localization/it.json @@ -201,11 +201,11 @@ "connection.wiredAdb": "ADB cablato", "connection.selectDevice": "Seleziona dispositivo", "connection.wireless": "Wireless", - "settings.connectionMode": "Connection Mode", - "connectionMode.network": "Network", - "connectionMode.dynamic": "Dynamic", - "connectionMode.nearby": "Nearby", - "settings.connectionMode.network.info": "Uses local area network only. Bluetooth LE is disabled.", - "settings.connectionMode.dynamic.info": "Uses Wi-Fi and Bluetooth LE with automatic fallback and switching.", - "settings.connectionMode.nearby.info": "Uses Bluetooth LE only. Local network discovery is disabled." + "settings.connectionMode": "Modalità di connessione", + "connectionMode.network": "Rete", + "connectionMode.dynamic": "Dinamico", + "connectionMode.nearby": "Nelle vicinanze", + "settings.connectionMode.network.info": "Utilizza solo la rete locale. Bluetooth LE è disabilitato.", + "settings.connectionMode.dynamic.info": "Utilizza Wi-Fi e Bluetooth LE con fallback e cambio automatico.", + "settings.connectionMode.nearby.info": "Utilizza solo Bluetooth LE. La ricerca della rete locale è disabilitata." } diff --git a/airsync-mac/Model/Message.swift b/airsync-mac/Model/Message.swift index addbeb1c..39f6bce8 100644 --- a/airsync-mac/Model/Message.swift +++ b/airsync-mac/Model/Message.swift @@ -35,6 +35,7 @@ enum MessageType: String, Codable { case wakeUpRequest // sent by Android right before it closes the socket for a user-initiated disconnect case userDisconnected + case pong // remote control (Mac) case remoteControl case volumeControl // outgoing from Mac (legacy/other direction)