├── .gitignore ├── Tests ├── LinuxMain.swift └── airdrop-cli-tests │ ├── XCTestManifests.swift │ └── AirdropCLITests.swift ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── contents.xcworkspacedata ├── Sources └── airdrop │ ├── main.swift │ ├── ConsoleIO.swift │ └── AirDropCLI.swift ├── Makefile ├── Package.swift ├── LICENSE └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | import AirdropCLITests 4 | 5 | var tests = [XCTestCaseEntry]() 6 | tests += AirdropCLITests.allTests() 7 | XCTMain(tests) 8 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Tests/airdrop-cli-tests/XCTestManifests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | #if !canImport(ObjectiveC) 4 | public func allTests() -> [XCTestCaseEntry] { 5 | return [ 6 | testCase(AirdropCLITests.allTests), 7 | ] 8 | } 9 | #endif 10 | -------------------------------------------------------------------------------- /Sources/airdrop/main.swift: -------------------------------------------------------------------------------- 1 | // 2 | // main.swift 3 | // airdrop 4 | // 5 | // Created by Volodymyr Klymenko on 2020-12-30. 6 | // 7 | 8 | import Cocoa 9 | 10 | let airDropCLI = AirDropCLI() 11 | let app = NSApplication.shared 12 | 13 | app.delegate = airDropCLI 14 | app.run() 15 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PREFIX ?= /usr/local 2 | BINDIR := $(PREFIX)/bin 3 | 4 | all: build 5 | 6 | build: 7 | swift build -c release --disable-sandbox $(FLAGS) 8 | 9 | install: build 10 | install -d $(DESTDIR)$(BINDIR) 11 | install -m 755 ".build/release/airdrop" "$(DESTDIR)$(BINDIR)" 12 | 13 | uninstall: 14 | rm -rf $(DESTDIR)$(BINDIR)/airdrop 15 | 16 | clean: 17 | rm -rf .build 18 | 19 | .PHONY: all build install uninstall clean 20 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.3 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "airdrop-cli", 8 | targets: [ 9 | .target( 10 | name: "airdrop", 11 | dependencies: []), 12 | .testTarget( 13 | name: "airdrop-cli-tests", 14 | dependencies: ["airdrop"]), 15 | ] 16 | ) 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Volodymyr Klymenko 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Sources/airdrop/ConsoleIO.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ConsoleIO.swift 3 | // airdrop 4 | // 5 | // Created by Volodymyr Klymenko on 2020-12-30. 6 | // 7 | 8 | import Foundation 9 | 10 | enum OutputType { 11 | case error 12 | case standard 13 | } 14 | 15 | class ConsoleIO { 16 | func writeMessage(_ message: String, to: OutputType = .standard) { 17 | switch to { 18 | case .standard: 19 | print("\(message)") 20 | case .error: 21 | fputs("\n❌ Error: \(message)\n", stderr) 22 | } 23 | } 24 | 25 | func printUsage() { 26 | let executableName = (CommandLine.arguments[0] as NSString).lastPathComponent 27 | 28 | writeMessage("USAGE: \(executableName) [file2] [file3] ...") 29 | writeMessage(" file1, file2, file3, ... – URLs or paths to files to AirDrop") 30 | writeMessage(" You can specify multiple items - both local files and web URLs, and you can mix them too.") 31 | writeMessage(" You can also pipe input from other commands: command | \(executableName)") 32 | writeMessage("\nEXAMPLES:") 33 | writeMessage(" \(executableName) document.pdf") 34 | writeMessage(" \(executableName) image1.jpg image2.png") 35 | writeMessage(" \(executableName) file.txt https://apple.com/") 36 | writeMessage(" find . -name '*.pdf' | \(executableName) -") 37 | writeMessage("\nOPTIONS:") 38 | writeMessage(" -h, --help – print help info") 39 | writeMessage(" - – read file paths from stdin") 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Tests/airdrop-cli-tests/AirdropCLITests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import class Foundation.Bundle 3 | 4 | final class AirdropCLITests: XCTestCase { 5 | func testExample() throws { 6 | // This is an example of a functional test case. 7 | // Use XCTAssert and related functions to verify your tests produce the correct 8 | // results. 9 | 10 | // Some of the APIs that we use below are available in macOS 10.13 and above. 11 | guard #available(macOS 10.13, *) else { 12 | return 13 | } 14 | 15 | let fooBinary = productsDirectory.appendingPathComponent("airdrop") 16 | 17 | let process = Process() 18 | process.executableURL = fooBinary 19 | 20 | let pipe = Pipe() 21 | process.standardOutput = pipe 22 | 23 | try process.run() 24 | process.waitUntilExit() 25 | 26 | let data = pipe.fileHandleForReading.readDataToEndOfFile() 27 | let output = String(data: data, encoding: .utf8) 28 | 29 | XCTAssertEqual(output, "Hello, world!\n") 30 | } 31 | 32 | /// Returns path to the built products directory. 33 | var productsDirectory: URL { 34 | #if os(macOS) 35 | for bundle in Bundle.allBundles where bundle.bundlePath.hasSuffix(".xctest") { 36 | return bundle.bundleURL.deletingLastPathComponent() 37 | } 38 | fatalError("couldn't find the products directory") 39 | #else 40 | return Bundle.main.bundleURL 41 | #endif 42 | } 43 | 44 | static var allTests = [ 45 | ("testExample", testExample), 46 | ] 47 | } 48 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # airdrop-cli 2 | 3 | A command-line tool that allows you to share files and URLs with Apple 4 | devices using AirDrop from your terminal. 5 | 6 | ## Prerequisities 7 | - Xcode 8 | - Xcode Command Line Tools 9 | 10 | ## Installation 11 | 12 | `airdrop-cli` is available for install with [Homebrew](https://brew.sh/) 13 | 14 | ``` 15 | brew install vldmrkl/formulae/airdrop-cli 16 | ``` 17 | 18 | or 19 | 20 | ``` 21 | brew tap vldmrkl/formulae 22 | brew install airdrop-cli 23 | ``` 24 | 25 | ### Building from source 26 | 27 | Before attempting to build this project from its source code, make sure 28 | that you have Xcode version 11.4 (or higher) & GNU `make` installed. 29 | 30 | Then, clone the project and use the Makefile 31 | 32 | ``` 33 | git clone https://github.com/vldmrkl/airdrop-cli.git 34 | cd airdrop-cli 35 | make 36 | sudo make install 37 | ``` 38 | 39 | By default, make will build and install the project within 40 | `/usr/local/bin`. This project location can be changed by appending 41 | a new prefix to the `PREFIX` variable. 42 | 43 | ``` 44 | make PREFIX="YOUR_PREFIX_HERE" 45 | ``` 46 | 47 | You can append other Swift flags, in case you may need them for your 48 | specific build, to the `FLAGS` variable. 49 | 50 | ## Usage 51 | 52 | ![airdrop-cli-demo](https://user-images.githubusercontent.com/26641473/103395121-762ef380-4afa-11eb-9bc8-6cf6068edf32.gif) 53 | 54 | To airdrop files, run: 55 | 56 | ``` 57 | airdrop /path/to/your/file 58 | ``` 59 | 60 | You can also airdrop URLs: 61 | 62 | ``` 63 | airdrop https://apple.com/ 64 | ``` 65 | 66 | You can pass as many paths as you want. As long as theese file URLs are correct, 67 | the command will work. 68 | -------------------------------------------------------------------------------- /Sources/airdrop/AirDropCLI.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AirDropCLI.swift 3 | // airdrop 4 | // 5 | // Created by Volodymyr Klymenko on 2020-12-30. 6 | // 7 | 8 | import Foundation 9 | 10 | import Cocoa 11 | 12 | enum OptionType: String { 13 | case help = "h" 14 | case unknown 15 | 16 | init(value: String) { 17 | switch value { 18 | case "-h", "--help": self = .help 19 | default: self = .unknown 20 | } 21 | } 22 | } 23 | 24 | class AirDropCLI: NSObject, NSApplicationDelegate, NSSharingServiceDelegate { 25 | let consoleIO = ConsoleIO() 26 | private var isIndividualSharing = false 27 | private var individualSharingItems: [URL] = [] 28 | private var individualSharingSuccessful = 0 29 | private var individualSharingFailed = 0 30 | private var sharingStartTime: Date? 31 | 32 | func applicationDidFinishLaunching(_ aNotification: Notification) { 33 | let argCount = Int(CommandLine.argc) 34 | 35 | if argCount >= 2 { 36 | let argument = CommandLine.arguments[1] 37 | if argCount == 2 && argument.hasPrefix("-") { 38 | if argument == "-" { 39 | // Process stdin 40 | let stdinPaths = readPathsFromStdin() 41 | if stdinPaths.isEmpty { 42 | consoleIO.printUsage() 43 | exit(0) 44 | } 45 | shareFiles(stdinPaths) 46 | } else { 47 | let (option, _) = getOption(argument) 48 | 49 | if option == .help { 50 | consoleIO.printUsage() 51 | } else { 52 | consoleIO.writeMessage("Unknown option, see usage.\n", to: .error) 53 | consoleIO.printUsage() 54 | } 55 | 56 | exit(0) 57 | } 58 | } else { 59 | let pathsToFiles = Array(CommandLine.arguments[1 ..< argCount]) 60 | shareFiles(pathsToFiles) 61 | } 62 | } else { 63 | consoleIO.printUsage() 64 | exit(0) 65 | } 66 | 67 | if #available(macOS 13.0, *) { 68 | NSApp.setActivationPolicy(.accessory) 69 | } 70 | } 71 | 72 | func getOption(_ option: String) -> (option:OptionType, value: String) { 73 | return (OptionType(value: option), option) 74 | } 75 | 76 | func shareFiles(_ pathsToFiles: [String]) { 77 | guard let service: NSSharingService = NSSharingService(named: .sendViaAirDrop) 78 | else { 79 | exit(2) 80 | } 81 | 82 | var filesToShare: [URL] = [] 83 | var invalidPaths: [String] = [] 84 | 85 | for pathToFile in pathsToFiles { 86 | if let url = URL(string: pathToFile), 87 | let scheme = url.scheme?.lowercased(), 88 | ["http", "https"].contains(scheme) { 89 | filesToShare.append(url) 90 | } else { 91 | let fileURL: URL = NSURL.fileURL(withPath: pathToFile, isDirectory: false) 92 | 93 | if FileManager.default.fileExists(atPath: fileURL.path) { 94 | filesToShare.append(fileURL.standardizedFileURL) 95 | } else { 96 | invalidPaths.append(pathToFile) 97 | } 98 | } 99 | } 100 | 101 | if !invalidPaths.isEmpty { 102 | consoleIO.writeMessage("Warning: The following paths are invalid") 103 | for path in invalidPaths { 104 | consoleIO.writeMessage(" \(path)") 105 | } 106 | } 107 | 108 | guard !filesToShare.isEmpty else { 109 | consoleIO.writeMessage("Warning: No valid files or URLs to share.") 110 | exit(1) 111 | } 112 | 113 | consoleIO.writeMessage("Sharing \(filesToShare.count) items:") 114 | for (index, url) in filesToShare.enumerated() { 115 | consoleIO.writeMessage(" \(index + 1). \(url)") 116 | } 117 | 118 | let hasURLs = filesToShare.contains { $0.scheme == "http" || $0.scheme == "https" } 119 | let hasFiles = filesToShare.contains { $0.scheme == "file" } 120 | let isMixedContent: Bool = hasURLs && hasFiles 121 | 122 | if isMixedContent { 123 | // Currently, AirDrop does not support sharing both URLs and files at once. Therefore, we need to share them individually. 124 | shareItemsIndividually(service: service, filesToShare) 125 | } else { 126 | if service.canPerform(withItems: filesToShare) { 127 | service.delegate = self 128 | service.perform(withItems: filesToShare) 129 | } else { 130 | // If we can't share all items at once, for example, when there is more than 1 URL, we need to share them individually 131 | shareItemsIndividually(service: service, filesToShare) 132 | } 133 | } 134 | } 135 | 136 | 137 | func sharingService(_ sharingService: NSSharingService, didShareItems items: [Any]) { 138 | if isIndividualSharing { 139 | individualSharingSuccessful += 1 140 | guard let service: NSSharingService = NSSharingService(named: .sendViaAirDrop) else { 141 | exit(2) 142 | } 143 | shareNextItem(service: service, remainingItems: individualSharingItems) 144 | } else { 145 | consoleIO.writeMessage("✅ Sharing completed: \(items.count) successful") 146 | exit(0) 147 | } 148 | } 149 | 150 | func sharingService(_ sharingService: NSSharingService, didFailToShareItems items: [Any], error: Error) { 151 | if isIndividualSharing { 152 | individualSharingFailed += 1 153 | consoleIO.writeMessage("Failed to share item: \(error.localizedDescription)", to: .error) 154 | 155 | guard let service: NSSharingService = NSSharingService(named: .sendViaAirDrop) else { 156 | exit(2) 157 | } 158 | shareNextItem(service: service, remainingItems: individualSharingItems) 159 | } else { 160 | consoleIO.writeMessage(error.localizedDescription, to: .error) 161 | exit(1) 162 | } 163 | } 164 | 165 | func sharingService(_ sharingService: NSSharingService, sourceFrameOnScreenForShareItem item: Any) -> NSRect { 166 | return NSRect(x: 0, y: 0, width: 400, height: 100) 167 | } 168 | 169 | func sharingService(_ sharingService: NSSharingService, sourceWindowForShareItems items: [Any], sharingContentScope: UnsafeMutablePointer) -> NSWindow? { 170 | let airDropMenuWindow = NSWindow(contentRect: .init(origin: .zero, 171 | size: .init(width: 1, 172 | height: 1)), 173 | styleMask: [.closable], 174 | backing: .buffered, 175 | defer: false) 176 | 177 | airDropMenuWindow.center() 178 | airDropMenuWindow.level = .popUpMenu 179 | airDropMenuWindow.makeKeyAndOrderFront(nil) 180 | 181 | return airDropMenuWindow 182 | } 183 | 184 | private func shareItemsIndividually(service: NSSharingService, _ items: [URL]) { 185 | isIndividualSharing = true 186 | individualSharingItems = items 187 | individualSharingSuccessful = 0 188 | individualSharingFailed = 0 189 | 190 | shareNextItem(service: service, remainingItems: items) 191 | } 192 | 193 | private func shareNextItem(service: NSSharingService, remainingItems: [URL]) { 194 | guard !remainingItems.isEmpty else { 195 | consoleIO.writeMessage("✅ Sharing completed: \(individualSharingSuccessful) successful, \(individualSharingFailed) failed") 196 | exit(individualSharingFailed > 0 ? 1 : 0) 197 | } 198 | 199 | let currentItem = remainingItems.first! 200 | let remainingItemsAfterCurrent = Array(remainingItems.dropFirst()) 201 | 202 | if service.canPerform(withItems: [currentItem]) { 203 | service.delegate = self 204 | service.perform(withItems: [currentItem]) 205 | individualSharingItems = remainingItemsAfterCurrent 206 | } else { 207 | consoleIO.writeMessage("Cannot share: \(currentItem)", to: .error) 208 | individualSharingFailed += 1 209 | shareNextItem(service: service, remainingItems: remainingItemsAfterCurrent) 210 | } 211 | } 212 | 213 | private func readPathsFromStdin() -> [String] { 214 | var paths: [String] = [] 215 | 216 | while let line = readLine() { 217 | let trimmedLine = line.trimmingCharacters(in: .whitespacesAndNewlines) 218 | if !trimmedLine.isEmpty { 219 | paths.append(trimmedLine) 220 | } 221 | } 222 | 223 | return paths 224 | } 225 | } 226 | --------------------------------------------------------------------------------