diff --git a/AirSync.xcodeproj/project.pbxproj b/AirSync.xcodeproj/project.pbxproj index d429f8f1..503b7dce 100644 --- a/AirSync.xcodeproj/project.pbxproj +++ b/AirSync.xcodeproj/project.pbxproj @@ -602,6 +602,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/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..30e9f814 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,42 +16,63 @@ 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() } } 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" @@ -66,13 +86,17 @@ final class ShareViewController: NSViewController { do { let urls = try await loader.prepare(items) try Task.checkCancellation() - detail.stringValue = "Opening AirSync..." - let appURL = Bundle.main.bundleURL + 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 = false + configuration.allowsRunningApplicationSubstitution = true openingStarted = true _ = try await NSWorkspace.shared.open( urls, @@ -89,15 +113,19 @@ 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) { - 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) diff --git a/README.md b/README.md index 7285264e..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 @@ -29,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