├── global.json ├── src ├── AirDropAnywhere.Core │ ├── libnative.so │ ├── InternalsVisibleTo.cs │ ├── Resources │ │ ├── AppleRootCA.crt │ │ └── ResourceLoader.cs │ ├── AirDropOptions.cs │ ├── Protocol │ │ ├── MediaCapabilities.cs │ │ ├── RecordData.cs │ │ ├── AskResponse.cs │ │ ├── FileMetadata.cs │ │ ├── DiscoverResponse.cs │ │ ├── DiscoverRequest.cs │ │ └── AskRequest.cs │ ├── Interop.cs │ ├── libnative.m │ ├── AirDropAnywhere.Core.csproj │ ├── AirDropReceiverFlags.cs │ ├── AirDropKestrelExtensions.cs │ ├── AirDropPeer.cs │ ├── Serialization │ │ ├── PropertyListSerializer.cs │ │ └── PropertyListConverter.cs │ ├── AirDropServiceCollectionExtensions.cs │ ├── AirDropEndpointRouteBuilderExtensions.cs │ ├── MulticastDns │ │ ├── UdpSocketExtensions.cs │ │ ├── MulticastDnsService.cs │ │ ├── UdpAwaitableSocketAsyncEventArgs.cs │ │ └── MulticastDnsServer.cs │ ├── Certificates │ │ └── CertificateManager.cs │ ├── Utils.cs │ ├── AirDropRouteHandler.cs │ ├── AirDropService.cs │ └── Compression │ │ └── CpioArchiveReader.cs └── AirDropAnywhere.Cli │ ├── Hubs │ ├── OnFileUploadedResponseMessage.cs │ ├── ConnectMessage.cs │ ├── CanAcceptFilesResponseMessage.cs │ ├── CanAcceptFileMetadata.cs │ ├── OnFileUploadedRequestMessage.cs │ ├── CanAcceptFilesRequestMessage.cs │ ├── PolymorphicJsonIncludeAttribute.cs │ ├── AirDropHubMessage.cs │ ├── PolymorphicJsonConverter.cs │ └── AirDropHub.cs │ ├── wwwroot │ └── index.html │ ├── appsettings.json │ ├── Properties │ └── launchSettings.json │ ├── Commands │ ├── CommandBase.cs │ ├── ServerCommand.cs │ └── ClientCommand.cs │ ├── AirDropAnywhere.Cli.csproj │ ├── Logging │ ├── SpectreInlineLoggerProvider.cs │ └── SpectreInlineLogger.cs │ └── Program.cs ├── tests └── AirDropAnywhere.Tests │ ├── test.large.cpio │ ├── test.multiple.cpio │ ├── test.single.cpio │ ├── OctalParsingTests.cs │ ├── test.nested.cpio │ ├── AirDropAnywhere.Tests.csproj │ └── CpioArchiveReaderTests.cs ├── .idea └── .idea.AirDropAnywhere │ └── .idea │ ├── encodings.xml │ ├── vcs.xml │ ├── indexLayout.xml │ └── .gitignore ├── nuget.config ├── version.json ├── .github └── workflows │ ├── build.yml │ └── publish.yml ├── .run ├── CLI_ Client.run.xml └── CLI_ Server.run.xml ├── LICENSE ├── Directory.Packages.props ├── Directory.Build.props ├── .vscode ├── launch.json └── tasks.json ├── restore.ps1 ├── README.md ├── restore.sh ├── AirDropAnywhere.sln └── .gitignore /global.json: -------------------------------------------------------------------------------- 1 | { 2 | "sdk": { 3 | "version": "6.0.100-rc.1.21463.6" 4 | } 5 | } -------------------------------------------------------------------------------- /src/AirDropAnywhere.Core/libnative.so: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanward81/AirDropAnywhere/HEAD/src/AirDropAnywhere.Core/libnative.so -------------------------------------------------------------------------------- /src/AirDropAnywhere.Core/InternalsVisibleTo.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.CompilerServices; 2 | 3 | [assembly:InternalsVisibleTo("AirDropAnywhere.Tests")] -------------------------------------------------------------------------------- /tests/AirDropAnywhere.Tests/test.large.cpio: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanward81/AirDropAnywhere/HEAD/tests/AirDropAnywhere.Tests/test.large.cpio -------------------------------------------------------------------------------- /tests/AirDropAnywhere.Tests/test.multiple.cpio: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanward81/AirDropAnywhere/HEAD/tests/AirDropAnywhere.Tests/test.multiple.cpio -------------------------------------------------------------------------------- /src/AirDropAnywhere.Core/Resources/AppleRootCA.crt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanward81/AirDropAnywhere/HEAD/src/AirDropAnywhere.Core/Resources/AppleRootCA.crt -------------------------------------------------------------------------------- /src/AirDropAnywhere.Cli/Hubs/OnFileUploadedResponseMessage.cs: -------------------------------------------------------------------------------- 1 | namespace AirDropAnywhere.Cli.Hubs 2 | { 3 | internal class OnFileUploadedResponseMessage : AirDropHubMessage 4 | { 5 | } 6 | } -------------------------------------------------------------------------------- /.idea/.idea.AirDropAnywhere/.idea/encodings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/AirDropAnywhere.Cli/wwwroot/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Hello World 6 | 7 | 8 | Hello World! 9 | 10 | -------------------------------------------------------------------------------- /src/AirDropAnywhere.Cli/Hubs/ConnectMessage.cs: -------------------------------------------------------------------------------- 1 | #nullable disable 2 | namespace AirDropAnywhere.Cli.Hubs 3 | { 4 | internal class ConnectMessage : AirDropHubMessage 5 | { 6 | public string Name { get; set; } 7 | } 8 | } -------------------------------------------------------------------------------- /.idea/.idea.AirDropAnywhere/.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/AirDropAnywhere.Cli/Hubs/CanAcceptFilesResponseMessage.cs: -------------------------------------------------------------------------------- 1 | namespace AirDropAnywhere.Cli.Hubs 2 | { 3 | internal class CanAcceptFilesResponseMessage : AirDropHubMessage 4 | { 5 | public bool Accepted { get; set; } 6 | } 7 | } -------------------------------------------------------------------------------- /.idea/.idea.AirDropAnywhere/.idea/indexLayout.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/AirDropAnywhere.Cli/Hubs/CanAcceptFileMetadata.cs: -------------------------------------------------------------------------------- 1 | #nullable disable 2 | namespace AirDropAnywhere.Cli.Hubs 3 | { 4 | internal class CanAcceptFileMetadata 5 | { 6 | public string Name { get; set; } 7 | public string Type { get; set; } 8 | } 9 | } -------------------------------------------------------------------------------- /src/AirDropAnywhere.Cli/Hubs/OnFileUploadedRequestMessage.cs: -------------------------------------------------------------------------------- 1 | #nullable disable 2 | namespace AirDropAnywhere.Cli.Hubs 3 | { 4 | internal class OnFileUploadedRequestMessage : AirDropHubMessage 5 | { 6 | public string Name { get; set; } 7 | public string Url { get; set; } 8 | } 9 | } -------------------------------------------------------------------------------- /src/AirDropAnywhere.Cli/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "System": "Warning", 6 | "Microsoft": "Warning", 7 | "Microsoft.AspNetCore.Hosting.Diagnostics": "Information" 8 | } 9 | } 10 | } -------------------------------------------------------------------------------- /nuget.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.idea/.idea.AirDropAnywhere/.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Rider ignored files 5 | /modules.xml 6 | /contentModel.xml 7 | /.idea.AirDropAnywhere.iml 8 | /projectSettingsUpdater.xml 9 | # Datasource local storage ignored files 10 | /dataSources/ 11 | /dataSources.local.xml 12 | # Editor-based HTTP Client requests 13 | /httpRequests/ 14 | -------------------------------------------------------------------------------- /src/AirDropAnywhere.Core/AirDropOptions.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | using Microsoft.Extensions.FileProviders; 3 | 4 | namespace AirDropAnywhere.Core 5 | { 6 | public class AirDropOptions 7 | { 8 | [Required] 9 | public ushort ListenPort { get; set; } 10 | 11 | [Required] 12 | public string UploadPath { get; set; } = null!; 13 | } 14 | } -------------------------------------------------------------------------------- /src/AirDropAnywhere.Core/Protocol/MediaCapabilities.cs: -------------------------------------------------------------------------------- 1 | namespace AirDropAnywhere.Core.Protocol 2 | { 3 | internal class MediaCapabilities 4 | { 5 | public static readonly MediaCapabilities Default = new(1); 6 | 7 | public MediaCapabilities(int version) 8 | { 9 | Version = version; 10 | } 11 | 12 | public int Version { get; private set; } 13 | } 14 | } -------------------------------------------------------------------------------- /src/AirDropAnywhere.Core/Protocol/RecordData.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | 4 | namespace AirDropAnywhere.Core.Protocol 5 | { 6 | internal class RecordData 7 | { 8 | public IEnumerable ValidatedEmailHashes { get; private set; } = Enumerable.Empty(); 9 | public IEnumerable ValidatedPhoneHashes { get; private set; } = Enumerable.Empty(); 10 | } 11 | } -------------------------------------------------------------------------------- /version.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.1", 3 | "versionHeightOffset": -1, 4 | "assemblyVersion": "0.1", 5 | "publicReleaseRefSpec": [ 6 | "^refs/heads/main$", 7 | "^refs/tags/v\\d+\\.\\d+" 8 | ], 9 | "nugetPackageVersion": { 10 | "semVer": 2 11 | }, 12 | "cloudBuild": { 13 | "buildNumber": { 14 | "enabled": true, 15 | "setVersionVariables": true 16 | } 17 | } 18 | } -------------------------------------------------------------------------------- /src/AirDropAnywhere.Core/Interop.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.InteropServices; 2 | 3 | namespace AirDropAnywhere.Core 4 | { 5 | internal static class Interop 6 | { 7 | [DllImport("libnative.so", EntryPoint = "StartAWDLBrowsing", SetLastError = true)] 8 | public static extern void StartAWDLBrowsing(); 9 | 10 | [DllImport("libnative.so", EntryPoint = "StopAWDLBrowsing", SetLastError = true)] 11 | public static extern void StopAWDLBrowsing(); 12 | } 13 | } -------------------------------------------------------------------------------- /src/AirDropAnywhere.Cli/Hubs/CanAcceptFilesRequestMessage.cs: -------------------------------------------------------------------------------- 1 | #nullable disable 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | 5 | namespace AirDropAnywhere.Cli.Hubs 6 | { 7 | internal class CanAcceptFilesRequestMessage : AirDropHubMessage 8 | { 9 | public string SenderComputerName { get; set; } 10 | public byte[] FileIcon { get; set; } 11 | public IEnumerable Files { get; set; } = Enumerable.Empty(); 12 | } 13 | } -------------------------------------------------------------------------------- /tests/AirDropAnywhere.Tests/test.single.cpio: -------------------------------------------------------------------------------- 1 | 0707077777770000011006440007650000240000010000001404402665400001100000000041test.txtThis is an example text document 2 | 0707070000000000000000000000000000000000010000000000000000000001300000000000TRAILER!!! -------------------------------------------------------------------------------- /src/AirDropAnywhere.Cli/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/launchsettings.json", 3 | "profiles": { 4 | "Server": { 5 | "commandName": "Project", 6 | "commandLineArgs": "server --port 8181", 7 | "environmentVariables": { 8 | "ASPNETCORE_ENVIRONMENT": "Local" 9 | } 10 | }, 11 | "Client": { 12 | "commandName": "Project", 13 | "commandLineArgs": "client --server localhost --port 8181 --path downloads", 14 | "environmentVariables": { 15 | "ASPNETCORE_ENVIRONMENT": "Local" 16 | } 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build & Test 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | paths-ignore: 8 | - 'docs/**' # Don't run workflow when files are only in the /docs directory 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | if: "!contains(github.event.head_commit.message, 'ci skip')" 14 | steps: 15 | - name: Checkout code 16 | uses: actions/checkout@v2 17 | with: 18 | fetch-depth: 0 19 | - uses: actions/setup-dotnet@v1 20 | - name: .NET Build 21 | run: dotnet build -c Release --nologo 22 | - name: .NET Test 23 | run: dotnet test -c Release --no-build --nologo -------------------------------------------------------------------------------- /src/AirDropAnywhere.Cli/Commands/CommandBase.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.Extensions.Logging; 3 | using Spectre.Console; 4 | using Spectre.Console.Cli; 5 | 6 | namespace AirDropAnywhere.Cli.Commands 7 | { 8 | public abstract class CommandBase : AsyncCommand where TSettings : CommandSettings 9 | { 10 | protected IAnsiConsole Console { get; } 11 | protected ILogger Logger { get; } 12 | 13 | protected CommandBase(IAnsiConsole console, ILogger logger) 14 | { 15 | Console = console ?? throw new ArgumentNullException(nameof(console)); 16 | Logger = logger ?? throw new ArgumentNullException(nameof(logger)); 17 | } 18 | } 19 | } -------------------------------------------------------------------------------- /src/AirDropAnywhere.Core/libnative.m: -------------------------------------------------------------------------------- 1 | /* 2 | * Code derived from https://github.com/yggdrasil-network/yggdrasil-go/blob/master/src/multicast/multicast_darwin.go 3 | * Forces the system to initialize AWDL on MacOS so that we can advertise AirDrop 4 | * services using it. 5 | */ 6 | #import 7 | NSNetServiceBrowser *serviceBrowser; 8 | void StartAWDLBrowsing() { 9 | if (serviceBrowser == nil) { 10 | serviceBrowser = [[NSNetServiceBrowser alloc] init]; 11 | serviceBrowser.includesPeerToPeer = YES; 12 | } 13 | [serviceBrowser searchForServicesOfType:@"_airdrop_proxy._tcp" inDomain:@""]; 14 | } 15 | void StopAWDLBrowsing() { 16 | if (serviceBrowser == nil) { 17 | return; 18 | } 19 | [serviceBrowser stop]; 20 | } -------------------------------------------------------------------------------- /src/AirDropAnywhere.Core/Protocol/AskResponse.cs: -------------------------------------------------------------------------------- 1 | namespace AirDropAnywhere.Core.Protocol 2 | { 3 | /// 4 | /// Body of a response from the /Ask endpoint in the AirDrop HTTP API. 5 | /// 6 | internal class AskResponse 7 | { 8 | public AskResponse(string computerName, string modelName) 9 | { 10 | ReceiverComputerName = computerName; 11 | ReceiverModelName = modelName; 12 | } 13 | 14 | /// 15 | /// Gets the receiver computer's name. 16 | /// 17 | public string ReceiverComputerName { get; } 18 | /// 19 | /// Gets the model name of the receiver. 20 | /// 21 | public string ReceiverModelName { get; } 22 | } 23 | } -------------------------------------------------------------------------------- /src/AirDropAnywhere.Cli/Hubs/PolymorphicJsonIncludeAttribute.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace AirDropAnywhere.Cli.Hubs 4 | { 5 | [AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] 6 | internal class PolymorphicJsonIncludeAttribute : Attribute 7 | { 8 | public PolymorphicJsonIncludeAttribute(string name, Type type) 9 | { 10 | Name = name ?? throw new ArgumentNullException(nameof(name)); 11 | Type = type ?? throw new ArgumentNullException(nameof(type)); 12 | } 13 | 14 | /// 15 | /// Gets the name of the mapping when serialized by the . 16 | /// 17 | public string Name { get; } 18 | 19 | /// 20 | /// Gets the that should be handled by the class. 21 | /// 22 | public Type Type { get; } 23 | } 24 | } -------------------------------------------------------------------------------- /.run/CLI_ Client.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | -------------------------------------------------------------------------------- /.run/CLI_ Server.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | -------------------------------------------------------------------------------- /src/AirDropAnywhere.Cli/AirDropAnywhere.Cli.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Exe 5 | net6.0 6 | true 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | PreserveNewest 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/AirDropAnywhere.Cli/Logging/SpectreInlineLoggerProvider.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Concurrent; 3 | using Microsoft.Extensions.Logging; 4 | using Spectre.Console; 5 | 6 | namespace AirDropAnywhere.Cli.Logging 7 | { 8 | internal class SpectreInlineLoggerProvider : ILoggerProvider 9 | { 10 | private readonly IAnsiConsole _console; 11 | private readonly ConcurrentDictionary _loggers = new(); 12 | 13 | public SpectreInlineLoggerProvider(IAnsiConsole console) 14 | { 15 | _console = console ?? throw new ArgumentNullException(nameof(console)); 16 | } 17 | 18 | public ILogger CreateLogger(string categoryName) 19 | { 20 | return _loggers.GetOrAdd(categoryName, name => new SpectreInlineLogger(name, _console)); 21 | } 22 | 23 | public void Dispose() 24 | { 25 | _loggers.Clear(); 26 | } 27 | } 28 | } -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Build, Test & Package 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths-ignore: 8 | - 'docs/**' # Don't run workflow when files are only in the /docs directory 9 | 10 | jobs: 11 | publish: 12 | runs-on: ubuntu-latest 13 | if: "!contains(github.event.head_commit.message, 'ci skip')" 14 | steps: 15 | - name: Checkout code 16 | uses: actions/checkout@v2 17 | with: 18 | fetch-depth: 0 19 | - uses: actions/setup-dotnet@v1 20 | - name: .NET Build 21 | run: dotnet build -c Release --nologo 22 | - name: .NET Test 23 | run: dotnet test -c Release --no-build --nologo 24 | - name: .NET Pack 25 | run: dotnet pack --no-build -c Release --nologo /p:PackageOutputPath=$GITHUB_WORKSPACE/.nupkgs 26 | - name: Push to GH Packages 27 | run: dotnet nuget push $GITHUB_WORKSPACE/.nupkgs/*.nupkg --source https://nuget.pkg.github.com/deanward81/index.json --api-key ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /src/AirDropAnywhere.Core/Protocol/FileMetadata.cs: -------------------------------------------------------------------------------- 1 | // ReSharper disable UnusedAutoPropertyAccessor.Local 2 | // ReSharper disable ClassNeverInstantiated.Global 3 | #pragma warning disable 8618 4 | namespace AirDropAnywhere.Core.Protocol 5 | { 6 | public class FileMetadata 7 | { 8 | /// 9 | /// Gets the name of the file. 10 | /// 11 | public string FileName { get; private set; } 12 | /// 13 | /// Gets the type of the file. 14 | /// 15 | public string FileType { get; private set; } 16 | /// 17 | /// Gets a value indicating whether the "file" is actually a directory. 18 | /// 19 | public bool FileIsDirectory { get; private set; } 20 | /// 21 | /// Gets a value indicating whether the file needs to be converted or not. 22 | /// 23 | public bool ConvertMediaFormats { get; private set; } 24 | } 25 | } -------------------------------------------------------------------------------- /tests/AirDropAnywhere.Tests/OctalParsingTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text; 3 | using AirDropAnywhere.Core; 4 | using Xunit; 5 | 6 | namespace AirDropAnywhere.Tests 7 | { 8 | public class OctalParsingTests 9 | { 10 | [Theory] 11 | [InlineData("7645342", 2050786, true)] 12 | [InlineData("00004567", 2423, true)] 13 | [InlineData("0", 0, true)] 14 | [InlineData("", 0, false)] // zero length 15 | [InlineData("7AB5342", 0, false)] // no numbers 16 | [InlineData("a string", 0, false)] // no numbers 17 | [InlineData("-3423423", 0, false)] // parsing to a ulong - sign is not allowed 18 | public void ParseOctalToNumber(string input, ulong expectedValue, bool success) 19 | { 20 | var stringAsByteSpan = Encoding.ASCII.GetBytes(input); 21 | Assert.Equal(success, Utils.TryParseOctalToUInt32(stringAsByteSpan, out var actualValue)); 22 | Assert.Equal(expectedValue, actualValue); 23 | } 24 | } 25 | } -------------------------------------------------------------------------------- /tests/AirDropAnywhere.Tests/test.nested.cpio: -------------------------------------------------------------------------------- 1 | 0707077777770000011006440007650000240000010000001405676516300002100000000005./test1/test.txttest 2 | 0707077777770000020407550007650000240000030000001405676515700001000000000000./test10707077777770000031006440007650000240000010000001405676524300002700000000005./test3/test4/test.csvtest 3 | 0707077777770000040407550007650000240000030000001405676524300001600000000000./test3/test40707077777770000050407550007650000240000030000001405676520400001000000000000./test30707077777770000061006440007650000240000010000001405676517400002100000000005./test2/test.logtest 4 | 0707077777770000070407550007650000240000030000001405676517400001000000000000./test20707077777770000100407550007650000240000050000001405676534500000200000000000.0707070000000000000000000000000000000000010000000000000000000001300000000000TRAILER!!! 5 | -------------------------------------------------------------------------------- /src/AirDropAnywhere.Core/AirDropAnywhere.Core.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | true 6 | AirDropAnywhere 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /tests/AirDropAnywhere.Tests/AirDropAnywhere.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | 6 | false 7 | 8 | 9 | 10 | 11 | 12 | 13 | runtime; build; native; contentfiles; analyzers; buildtransitive 14 | all 15 | 16 | 17 | runtime; build; native; contentfiles; analyzers; buildtransitive 18 | all 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Dean Ward 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 | -------------------------------------------------------------------------------- /src/AirDropAnywhere.Core/Resources/ResourceLoader.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Runtime.CompilerServices; 4 | using System.Security.Cryptography.X509Certificates; 5 | 6 | namespace AirDropAnywhere.Core.Resources 7 | { 8 | internal static class ResourceLoader 9 | { 10 | private static X509Certificate2? _appleRootCA; 11 | 12 | public static X509Certificate2 AppleRootCA => 13 | _appleRootCA ??= GetResource( 14 | "AppleRootCA.crt", 15 | s => 16 | { 17 | Span readBuffer = stackalloc byte[(int) s.Length]; 18 | s.Read(readBuffer); 19 | return new X509Certificate2(readBuffer); 20 | }); 21 | 22 | private static T GetResource(string resourceName, Func converter) 23 | { 24 | using var resourceStream = typeof(ResourceLoader).Assembly.GetManifestResourceStream( 25 | typeof(ResourceLoader), 26 | resourceName! 27 | ); 28 | 29 | if (resourceStream == null) 30 | { 31 | throw new ArgumentException($"Could not load resource '{resourceName}'"); 32 | } 33 | 34 | return converter(resourceStream); 35 | } 36 | } 37 | } -------------------------------------------------------------------------------- /Directory.Packages.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6.0.0-rc.1.21452.15 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/AirDropAnywhere.Core/AirDropReceiverFlags.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace AirDropAnywhere.Core 4 | { 5 | /// 6 | /// Flags attached to mDNS responses indicating what is supported by 7 | /// the AirDrop service. 8 | /// 9 | /// 10 | /// Derived from OpenDrop: https://github.com/seemoo-lab/opendrop/blob/master/opendrop/config.py#L32-L51 11 | /// On MacOS the default is configured to be 0x3fb which is defined in . 12 | /// OpenDrop currently supports a subset of this so we'll use that as our default for now. 13 | /// 14 | [Flags] 15 | internal enum AirDropReceiverFlags : ushort 16 | { 17 | Url = 1 << 0, 18 | DvZip = 1 << 1, 19 | Pipelining = 1 << 2, 20 | MixedTypes = 1 << 3, 21 | Unknown1 = 1 << 4, 22 | Unknown2 = 1 << 5, 23 | Iris = 1 << 6, 24 | Discover = 1 << 7, 25 | Unknown3 = 1 << 8, 26 | AssetBundle = 1 << 9, 27 | 28 | /// 29 | /// Default broadcast by MacOS 30 | /// 31 | DefaultMacOS = Url | DvZip | MixedTypes | Unknown1 | Unknown2 | Iris | Discover | Unknown3 | AssetBundle, 32 | /// 33 | /// Default used by AirDropAnywhere, will extend as more of AirDrop is implemented. 34 | /// 35 | Default = Url | Pipelining | MixedTypes | Discover | AssetBundle, 36 | } 37 | } -------------------------------------------------------------------------------- /src/AirDropAnywhere.Core/Protocol/DiscoverResponse.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | 3 | namespace AirDropAnywhere.Core.Protocol 4 | { 5 | /// 6 | /// Body of a response from the /Discover endpoint in the AirDrop HTTP API. 7 | /// 8 | internal class DiscoverResponse 9 | { 10 | public DiscoverResponse(string computerName, string modelName, MediaCapabilities mediaCapabilities) 11 | { 12 | ReceiverComputerName = computerName; 13 | ReceiverModelName = modelName; 14 | ReceiverMediaCapabilities = JsonSerializer.SerializeToUtf8Bytes(mediaCapabilities); 15 | // TODO: implement contact data 16 | //ReceiverRecordData = Array.Empty(); 17 | } 18 | 19 | /// 20 | /// Gets the receiver computer's name. Displayed when selecting a "contact" to send to. 21 | /// 22 | public string ReceiverComputerName { get; } 23 | /// 24 | /// Gets the model name of the receiver. 25 | /// 26 | public string ReceiverModelName { get; } 27 | /// 28 | /// Gets the UTF-8 encoded bytes of a JSON payload detailing the 29 | /// media capabilities of the receiver. 30 | /// 31 | /// 32 | /// This payload is represented in code by the class. 33 | /// 34 | public byte[] ReceiverMediaCapabilities { get; } 35 | //public byte[] ReceiverRecordData { get; } 36 | } 37 | } -------------------------------------------------------------------------------- /src/AirDropAnywhere.Core/AirDropKestrelExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Security.Cryptography.X509Certificates; 3 | using AirDropAnywhere.Core; 4 | using Microsoft.AspNetCore.Server.Kestrel.Core; 5 | using Microsoft.Extensions.DependencyInjection; 6 | using Microsoft.Extensions.Options; 7 | 8 | // ReSharper disable once CheckNamespace 9 | namespace Microsoft.AspNetCore.Hosting 10 | { 11 | /// 12 | /// Extensions used to configure AirDrop defaults in Kestrel. 13 | /// 14 | public static class AirDropKestrelExtensions 15 | { 16 | /// 17 | /// Configures AirDrop defaults for Kestrel. 18 | /// 19 | /// A instance to configure. 20 | /// An representing the certificate to use for the AirDrop HTTPS endpoint. 21 | public static void ConfigureAirDropDefaults(this KestrelServerOptions options, X509Certificate2 cert) 22 | { 23 | if (cert == null) 24 | { 25 | throw new ArgumentNullException(nameof(cert)); 26 | } 27 | 28 | var airDropOptions = options.ApplicationServices.GetRequiredService>(); 29 | options.ConfigureEndpointDefaults( 30 | endpointDefaults => 31 | { 32 | endpointDefaults.UseHttps(cert); 33 | }); 34 | 35 | options.ListenAnyIP(airDropOptions.Value.ListenPort); 36 | } 37 | } 38 | } -------------------------------------------------------------------------------- /Directory.Build.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | 1.0.0 4 | 2021 Dean Ward 5 | strict 6 | latest 7 | enable 8 | Dean Ward 9 | https://github.com/deanward81/AirDropAnywhere 10 | MIT 11 | https://github.com/deanward81/AirDropAnywhere 12 | git 13 | true 14 | embedded 15 | en-GB 16 | false 17 | true 18 | false 19 | true 20 | true 21 | true 22 | 23 | 24 | true 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /src/AirDropAnywhere.Core/AirDropPeer.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using AirDropAnywhere.Core.Protocol; 3 | 4 | namespace AirDropAnywhere.Core 5 | { 6 | /// 7 | /// Exposes a way for the AirDrop HTTP API to communicate with an arbitrary peer that does 8 | /// not directly support the AirDrop protocol. 9 | /// 10 | public abstract class AirDropPeer 11 | { 12 | protected AirDropPeer() 13 | { 14 | Id = Utils.GetRandomString(); 15 | Name = Id; 16 | } 17 | 18 | /// 19 | /// Gets the unique identifier of this peer. 20 | /// 21 | public string Id { get; } 22 | 23 | /// 24 | /// Gets the (display) name of this peer. 25 | /// 26 | public string Name { get; protected set; } 27 | 28 | /// 29 | /// Determines whether the peer wants to receive files from a sender. 30 | /// 31 | /// 32 | /// An object representing information about the sender 33 | /// and the files that they wish to send. 34 | /// sender 35 | /// 36 | /// 37 | /// true if the receiver wants to accept the file transfer, false otherwise. 38 | /// 39 | public abstract ValueTask CanAcceptFilesAsync(AskRequest request); 40 | 41 | /// 42 | /// Notifies the peer that a file has been uploaded. This method is for every 43 | /// file extracted from the archive sent by an AirDrop-compatible device. 44 | /// 45 | /// 46 | /// Path to an extracted file. 47 | /// 48 | public abstract ValueTask OnFileUploadedAsync(string filePath); 49 | } 50 | } -------------------------------------------------------------------------------- /src/AirDropAnywhere.Core/Protocol/DiscoverRequest.cs: -------------------------------------------------------------------------------- 1 | // ReSharper disable AutoPropertyCanBeMadeGetOnly.Local 2 | 3 | using System; 4 | using System.Security.Cryptography.Pkcs; 5 | using System.Security.Cryptography.X509Certificates; 6 | using AirDropAnywhere.Core.Resources; 7 | using AirDropAnywhere.Core.Serialization; 8 | 9 | namespace AirDropAnywhere.Core.Protocol 10 | { 11 | /// 12 | /// Body of a request to the /Discover endpoint in the AirDrop HTTP API. 13 | /// 14 | internal class DiscoverRequest 15 | { 16 | /// 17 | /// Gets a binary blob representing a PKCS7 signed plist containing 18 | /// sender email and phone hashes. This is validated and deserialized into a 19 | /// object by . 20 | /// 21 | public byte[] SenderRecordData { get; private set; } = Array.Empty(); 22 | 23 | public bool TryGetSenderRecordData(out RecordData? recordData) 24 | { 25 | if (SenderRecordData == null || SenderRecordData.Length == 0) 26 | { 27 | recordData = default; 28 | return false; 29 | } 30 | 31 | // validate that the signature is valid 32 | var signedCms = new SignedCms(); 33 | try 34 | { 35 | signedCms.Decode(SenderRecordData); 36 | signedCms.CheckSignature( 37 | new X509Certificate2Collection(ResourceLoader.AppleRootCA), true 38 | ); 39 | } 40 | catch 41 | { 42 | recordData = default; 43 | return false; 44 | } 45 | 46 | recordData = PropertyListSerializer.Deserialize(signedCms.ContentInfo.Content); 47 | return true; 48 | } 49 | } 50 | } -------------------------------------------------------------------------------- /src/AirDropAnywhere.Cli/Hubs/AirDropHubMessage.cs: -------------------------------------------------------------------------------- 1 | #nullable disable 2 | using System; 3 | using System.Threading.Tasks; 4 | 5 | namespace AirDropAnywhere.Cli.Hubs 6 | { 7 | [PolymorphicJsonInclude("connect", typeof(ConnectMessage))] 8 | [PolymorphicJsonInclude("askRequest", typeof(CanAcceptFilesRequestMessage))] 9 | [PolymorphicJsonInclude("askResponse", typeof(CanAcceptFilesResponseMessage))] 10 | [PolymorphicJsonInclude("fileUploadRequest", typeof(OnFileUploadedRequestMessage))] 11 | [PolymorphicJsonInclude("fileUploadResponse", typeof(OnFileUploadedResponseMessage))] 12 | internal abstract class AirDropHubMessage 13 | { 14 | public string Id { get; set; } 15 | public string ReplyTo { get; set; } 16 | 17 | public static async ValueTask CreateAsync(Func modifier, TState state) where TMessage : AirDropHubMessage, new() 18 | { 19 | if (modifier == null) 20 | { 21 | throw new ArgumentNullException(nameof(modifier)); 22 | } 23 | 24 | var message = Create(); 25 | await modifier(message, state); 26 | return message; 27 | } 28 | 29 | public static async ValueTask CreateAsync(Func modifier) where TMessage : AirDropHubMessage, new() 30 | { 31 | if (modifier == null) 32 | { 33 | throw new ArgumentNullException(nameof(modifier)); 34 | } 35 | 36 | var message = Create(); 37 | await modifier(message); 38 | return message; 39 | } 40 | 41 | private static TMessage Create() where TMessage : AirDropHubMessage, new() => 42 | new() 43 | { 44 | Id = Guid.NewGuid().ToString("N"), 45 | }; 46 | } 47 | } -------------------------------------------------------------------------------- /src/AirDropAnywhere.Cli/Logging/SpectreInlineLogger.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text; 3 | using Microsoft.Extensions.Logging; 4 | using Spectre.Console; 5 | 6 | namespace AirDropAnywhere.Cli.Logging 7 | { 8 | internal class SpectreInlineLogger : ILogger 9 | { 10 | private readonly string _name; 11 | private readonly IAnsiConsole _console; 12 | 13 | public SpectreInlineLogger(string name, IAnsiConsole console) 14 | { 15 | _name = name; 16 | _console = console; 17 | } 18 | 19 | public IDisposable BeginScope(TState state) => null!; 20 | 21 | public bool IsEnabled(LogLevel logLevel) => true; 22 | 23 | /// 24 | public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) 25 | { 26 | if (!IsEnabled(logLevel)) 27 | { 28 | return; 29 | } 30 | 31 | var stringBuilder = new StringBuilder(80); 32 | stringBuilder.Append(GetLevelMarkup(logLevel)); 33 | stringBuilder.AppendFormat("[dim grey]{0}[/] ", _name); 34 | stringBuilder.Append(Markup.Escape(formatter(state, exception))); 35 | _console.MarkupLine(stringBuilder.ToString()); 36 | } 37 | 38 | private static string GetLevelMarkup(LogLevel level) 39 | { 40 | return level switch 41 | { 42 | LogLevel.Critical => "[bold underline white on red]|CRIT|:[/] ", 43 | LogLevel.Error => "[bold red]|ERROR|:[/] ", 44 | LogLevel.Warning => "[bold orange3]| WARN|:[/] ", 45 | LogLevel.Information => "[bold dim]| INFO|:[/] ", 46 | LogLevel.Debug => "[dim]|DEBUG|:[/] ", 47 | LogLevel.Trace => "[dim grey]|TRACE|:[/] ", 48 | _ => "|UNKWN|: " 49 | }; 50 | } 51 | } 52 | } -------------------------------------------------------------------------------- /src/AirDropAnywhere.Core/Protocol/AskRequest.cs: -------------------------------------------------------------------------------- 1 | // ReSharper disable UnusedAutoPropertyAccessor.Local 2 | // ReSharper disable ClassNeverInstantiated.Global 3 | // ReSharper disable InconsistentNaming 4 | #pragma warning disable 8618 5 | using System.Collections.Generic; 6 | using System.Linq; 7 | 8 | namespace AirDropAnywhere.Core.Protocol 9 | { 10 | /// 11 | /// Body of a request to the /Ask endpoint in the AirDrop HTTP API. 12 | /// 13 | public class AskRequest 14 | { 15 | /// 16 | /// Gets the sender computer's name. Displayed when asking for receiving a file not from a contact 17 | /// 18 | public string SenderComputerName { get; private set; } 19 | /// 20 | /// Gets the model name of the sender 21 | /// 22 | public string SenderModelName { get; private set; } 23 | /// 24 | /// Gets the service id distributed over mDNS 25 | /// 26 | public string SenderID { get; private set; } 27 | /// 28 | /// Gets the bundle id of the sending application 29 | /// 30 | public string BundleID { get; private set; } 31 | /// 32 | /// Gets a value indicating whether the sender wants that media formats are converted 33 | /// 34 | public bool ConvertMediaFormats { get; private set; } 35 | /// 36 | /// Gets the sender's contact information. 37 | /// 38 | public byte[] SenderRecordData { get; private set; } 39 | /// 40 | /// Gets a JPEG2000 encoded file icon used for display. 41 | /// 42 | public byte[] FileIcon { get; private set; } 43 | /// 44 | /// Gets an of objects 45 | /// containing metadata about the files the sender wishes to send. 46 | /// 47 | public IEnumerable Files { get; private set; } = Enumerable.Empty(); 48 | } 49 | } -------------------------------------------------------------------------------- /src/AirDropAnywhere.Cli/Program.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Net.Http; 3 | using System.Net.Security; 4 | using System.Threading.Tasks; 5 | using AirDropAnywhere.Cli.Commands; 6 | using AirDropAnywhere.Cli.Logging; 7 | using Microsoft.Extensions.DependencyInjection; 8 | using Microsoft.Extensions.Logging; 9 | using Spectre.Cli.Extensions.DependencyInjection; 10 | using Spectre.Console; 11 | using Spectre.Console.Cli; 12 | 13 | namespace AirDropAnywhere.Cli 14 | { 15 | internal static class Program 16 | { 17 | public const string ApplicationName = "🌐 AirDrop Anywhere"; 18 | public static Task Main(string[] args) 19 | { 20 | var services = new ServiceCollection(); 21 | 22 | services 23 | .AddHttpClient("airdrop") 24 | .ConfigurePrimaryHttpMessageHandler( 25 | () => new SocketsHttpHandler 26 | { 27 | // we using a self-signed certificate, so ignore it 28 | SslOptions = new SslClientAuthenticationOptions 29 | { 30 | RemoteCertificateValidationCallback = delegate { return true; } 31 | } 32 | }); 33 | 34 | services 35 | .AddSingleton(AnsiConsole.Console) 36 | .AddLogging( 37 | builder => 38 | { 39 | builder.ClearProviders(); 40 | builder.AddProvider(new SpectreInlineLoggerProvider(AnsiConsole.Console)); 41 | } 42 | ); 43 | 44 | var typeRegistrar = new DependencyInjectionRegistrar(services); 45 | var app = new CommandApp(typeRegistrar); 46 | app.Configure( 47 | c => 48 | { 49 | c.AddCommand("server"); 50 | c.AddCommand("client"); 51 | } 52 | ); 53 | 54 | AnsiConsole.WriteLine(ApplicationName); 55 | return app.RunAsync(args); 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to find out which attributes exist for C# debugging 3 | // Use hover for the description of the existing attributes 4 | // For further information visit https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.mdn 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": ".NET Core Launch (console)", 9 | "type": "coreclr", 10 | "request": "launch", 11 | "preLaunchTask": "build", 12 | // If you have changed target frameworks, make sure to update the program path. 13 | "program": "${workspaceFolder}/bin/Debug/net6.0/${workspaceFolderBasename}.dll", 14 | "args": [], 15 | "cwd": "${workspaceFolder}", 16 | // For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console 17 | "console": "internalConsole", 18 | "stopAtEntry": false 19 | }, 20 | { 21 | "name": ".NET Core Attach", 22 | "type": "coreclr", 23 | "request": "attach", 24 | "processId": "${command:pickProcess}" 25 | }, 26 | { 27 | "name": ".NET Core Launch (remote)", 28 | "type": "coreclr", 29 | "request": "launch", 30 | "preLaunchTask": "RaspberryPiDeploy", 31 | "program": "./AirDropAnywhere.Cli", 32 | "args": [ 33 | "server", 34 | "--port", 35 | "8080" 36 | ], 37 | "cwd": "~/src/${workspaceFolderBasename}", 38 | "stopAtEntry": false, 39 | "console": "internalConsole", 40 | "pipeTransport": { 41 | "pipeCwd": "${workspaceFolder}", 42 | "pipeProgram": "ssh", 43 | "pipeArgs": [ 44 | "pi@${input:host}" 45 | ], 46 | "debuggerPath": "~/vsdbg/vsdbg" 47 | } 48 | } 49 | ], 50 | "inputs": [ 51 | { 52 | "id": "host", 53 | "description": "Raspberry Pi host to debug", 54 | "default": "dward-pi", 55 | "type": "promptString" 56 | } 57 | ] 58 | } -------------------------------------------------------------------------------- /restore.ps1: -------------------------------------------------------------------------------- 1 | # Admin scripts from https://www.autoitscript.com/forum/topic/174609-powershell-script-to-self-elevate/ 2 | function Test-IsAdmin() 3 | { 4 | # Get the current ID and its security principal 5 | $windowsID = [System.Security.Principal.WindowsIdentity]::GetCurrent() 6 | $windowsPrincipal = new-object System.Security.Principal.WindowsPrincipal($windowsID) 7 | 8 | # Get the Admin role security principal 9 | $adminRole=[System.Security.Principal.WindowsBuiltInRole]::Administrator 10 | 11 | # Are we an admin role? 12 | if ($windowsPrincipal.IsInRole($adminRole)) 13 | { 14 | $true 15 | } 16 | else 17 | { 18 | $false 19 | } 20 | } 21 | 22 | if (!$IsWindows) { 23 | Write-Host -ForegroundColor Yellow "This script should only be run on Windows" 24 | return 25 | } 26 | 27 | if (!(Test-IsAdmin)) { 28 | Write-Host -ForegroundColor Yellow "This script should only be run as an admin" 29 | return 30 | } 31 | 32 | # work out which version of .NET we should be running (based upon global.json) 33 | Set-ExecutionPolicy -ExecutionPolicy Unrestricted -Scope CurrentUser 34 | $globalJson = Get-Content -Raw -Path 'global.json' | ConvertFrom-Json 35 | $dotnetVersion = $globalJson.sdk.version 36 | 37 | Write-Host -ForegroundColor Green "Found .NET SDK version '$dotnetVersion'" 38 | 39 | $dotnetPath = Join-Path $env:LOCALAPPDATA "Microsoft" "dotnet" 40 | if (!(Test-Path -Path $dotnetPath -PathType Container)) { 41 | Write-Host -ForegroundColor Green "Creating '$dotnetPath'" 42 | New-Item -ItemType Directory -Path $dotnetPath 43 | } 44 | 45 | # check to see if the installation root is in path _above_ the system-wide installation path 46 | $machinePath = [Environment]::GetEnvironmentVariable("Path", [System.EnvironmentVariableTarget]::Machine) 47 | $paths = $machinePath.Split(';') 48 | $systemIndex = $paths.IndexOf((Join-Path $env:ProgramFiles "dotnet" "\")) 49 | $userIndex = $paths.IndexOf($dotnetPath) 50 | if ($userIndex -eq -1 -or $systemIndex -lt $userIndex) { 51 | Write-Host -ForegroundColor Green "Updating PATH to include '$dotnetPath'" 52 | 53 | [Environment]::SetEnvironmentVariable("Path", $dotnetPath + ";" + $machinePath, [System.EnvironmentVariableTarget]::Machine) 54 | } 55 | 56 | & ./build/dotnet-install.ps1 -Version $dotnetVersion -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "build", 6 | "command": "dotnet", 7 | "type": "process", 8 | "args": [ 9 | "build", 10 | "${workspaceFolder}", 11 | "/property:GenerateFullPaths=true", 12 | "/consoleloggerparameters:NoSummary" 13 | ], 14 | "problemMatcher": "$msCompile" 15 | }, 16 | { 17 | "label": "publish", 18 | "command": "dotnet", 19 | "type": "process", 20 | "args": [ 21 | "publish", 22 | "${workspaceFolder}", 23 | "/property:GenerateFullPaths=true", 24 | "/consoleloggerparameters:NoSummary" 25 | ], 26 | "problemMatcher": "$msCompile" 27 | }, 28 | { 29 | "label": "watch", 30 | "command": "dotnet", 31 | "type": "process", 32 | "args": [ 33 | "watch", 34 | "run", 35 | "${workspaceFolder}", 36 | "/property:GenerateFullPaths=true", 37 | "/consoleloggerparameters:NoSummary" 38 | ], 39 | "problemMatcher": "$msCompile" 40 | }, 41 | { 42 | "label": "RaspberryPiPublish", 43 | "command": "dotnet", 44 | "type": "process", 45 | "args": [ 46 | "publish", 47 | "-r:linux-arm", 48 | "-o:bin/linux-arm/publish", 49 | "--self-contained" 50 | ], 51 | "problemMatcher": "$msCompile" 52 | }, 53 | { 54 | "label": "RaspberryPiDeploy", 55 | "type": "shell", 56 | "dependsOn": "RaspberryPiPublish", 57 | "presentation": { 58 | "reveal": "always", 59 | "panel": "new" 60 | }, 61 | "command": "rsync -rvuz --rsh=ssh bin/linux-arm/publish/ pi@${input:host}:~/src/${workspaceFolderBasename}/", 62 | "problemMatcher": [], 63 | } 64 | ], 65 | "inputs": [ 66 | { 67 | "id": "host", 68 | "description": "Raspberry Pi host to deploy to", 69 | "default": "dward-pi", 70 | "type": "promptString" 71 | } 72 | ] 73 | } -------------------------------------------------------------------------------- /src/AirDropAnywhere.Core/Serialization/PropertyListSerializer.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Buffers; 3 | using System.IO; 4 | using System.Threading.Tasks; 5 | using Claunia.PropertyList; 6 | 7 | namespace AirDropAnywhere.Core.Serialization 8 | { 9 | /// 10 | /// Helper class for serializing .NET types to / from property lists. 11 | /// 12 | internal class PropertyListSerializer 13 | { 14 | public const int MaxPropertyListLength = 1024 * 1024; // 1 MiB 15 | 16 | public static async ValueTask DeserializeAsync(Stream stream) 17 | { 18 | // this probably all seems a little convoluted but 19 | // plist-cil works best when it's passed a ReadOnlySpan 20 | // so try to minimize allocations as much as possible in this path 21 | var buffer = ArrayPool.Shared.Rent(MaxPropertyListLength); 22 | try 23 | { 24 | using (var memoryStream = new MemoryStream(buffer, 0, MaxPropertyListLength, true)) 25 | { 26 | await stream.CopyToAsync(memoryStream, 4096); 27 | return Deserialize(buffer.AsSpan()[..(int)memoryStream.Position]); 28 | } 29 | } 30 | finally 31 | { 32 | ArrayPool.Shared.Return(buffer); 33 | } 34 | } 35 | 36 | public static T Deserialize(ReadOnlySpan buffer) 37 | { 38 | return PropertyListConverter.ToObject( 39 | PropertyListParser.Parse(buffer) 40 | ); 41 | } 42 | 43 | public static async ValueTask SerializeAsync(object obj, Stream stream) 44 | { 45 | var buffer = ArrayPool.Shared.Rent(MaxPropertyListLength); 46 | try 47 | { 48 | using (var memoryStream = new MemoryStream(buffer, true)) 49 | { 50 | BinaryPropertyListWriter.Write( 51 | memoryStream, PropertyListConverter.ToNSObject(obj) 52 | ); 53 | 54 | await stream.WriteAsync(buffer, 0, (int)memoryStream.Position); 55 | } 56 | } 57 | finally 58 | { 59 | ArrayPool.Shared.Return(buffer); 60 | } 61 | } 62 | } 63 | } -------------------------------------------------------------------------------- /src/AirDropAnywhere.Core/AirDropServiceCollectionExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Net; 3 | using System.Runtime.InteropServices; 4 | using AirDropAnywhere.Core; 5 | using Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets; 6 | using Microsoft.Extensions.Hosting; 7 | 8 | // ReSharper disable once CheckNamespace 9 | namespace Microsoft.Extensions.DependencyInjection 10 | { 11 | /// 12 | /// Extensions used to add AirDrop services to . 13 | /// 14 | public static class AirDropServiceCollectionExtensions 15 | { 16 | /// 17 | /// Adds AirDrop services to the specified . 18 | /// 19 | /// The to add services to. 20 | /// An to configure the provided . 21 | public static IServiceCollection AddAirDrop(this IServiceCollection services, Action? setupAction = null) 22 | { 23 | if (services == null) 24 | { 25 | throw new ArgumentNullException(nameof(services)); 26 | } 27 | 28 | Utils.AssertPlatform(); 29 | Utils.AssertNetworkInterfaces(); 30 | 31 | services.AddScoped(); 32 | services.AddSingleton(); 33 | services.AddSingleton(s => s.GetService()!); 34 | services.AddOptions().ValidateDataAnnotations(); 35 | 36 | services.Configure( 37 | x => 38 | { 39 | // on macOS, ensure we listen on the awdl0 interface 40 | // by setting the SO_RECV_ANYIF socket option 41 | if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) 42 | { 43 | x.CreateBoundListenSocket = endpoint => 44 | { 45 | var socket = SocketTransportOptions.CreateDefaultBoundListenSocket(endpoint); 46 | if (endpoint is IPEndPoint) 47 | { 48 | socket.SetAwdlSocketOption(); 49 | } 50 | return socket; 51 | }; 52 | } 53 | }); 54 | 55 | if (setupAction != null) 56 | { 57 | services.Configure(setupAction); 58 | } 59 | 60 | return services; 61 | } 62 | } 63 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AirDrop Anywhere 2 | 3 | > **NOTE** This project is still a work in progress - some of the details below are the intended end state of the project, not the current state! More information can be found at my blog at https://bakedbean.org.uk/tags/airdrop/ 4 | 5 | A .NET Core implementation of the AirDrop protocol that allows arbitrary devices to send/receive files from devices that support AirDrop natively (i.e. Apple devices). 6 | 7 | ## Overview 8 | 9 | AirDropAnywhere implements the mDNS listener and HTTP API needed to handle the AirDrop protocol in such a way that they can be consumed by any application. However, in order to be able to successfully use AirDrop the hardware executing these components needs to support Apple Wireless Direct Link (AWDL) or Open Wireless Link ([OWL](https://owlink.org/)) - that usually means an Apple device or Linux with a wireless interface that supports RFMON. 10 | 11 | It also exposes a web server that serves up a website that can be used by arbitrary devices that do not support AirDrop natively to send/receive files to/from devices that do support AirDrop. 12 | 13 | ## Structure 14 | 15 | AirDropAnywhere is split into several projects: 16 | 17 | `AirDropAnywhere.Core` contains all our shared services (e.g. mDNS listener, core AirDrop HTTP API) and the means to configure them in a `WebHost`. This is exposed as a NuGet package called `AirDropAnywhere`. 18 | 19 | `AirDropAnywhere.Cli` is a CLI application hosting the services and rendered using [Spectre.Console](https://github.com/spectreconsole/spectre.console). It'll typically be used as a way to send / receive for the current machine via the command line. 20 | 21 | `AirDropAnywhere.Web` hosts the services and exposes a website that any device in the same network can connect to and be able to send/receive files to/from AirDrop-compatible devices. It makes use of Vue.js for its UI and SignalR for realtime communication needed for the backend to function. 22 | 23 | ## Components 24 | 25 | ### mDNS 26 | mDNS is implemented as an `IHostedService` that executes as part of the `WebHost`. Its sole purpose is to dynamically advertise each device that wants to send/receive files using AirDrop. It listens on IPv6 and IPv4 multicast on UDP 5353 and responds to mDNS queries over the AWDL interface exposed by Apple hardware or OWL's virtual interface. 27 | 28 | Bulk of the implementation is in the [MulticastDns](https://github.com/deanward81/AirDropAnywhere/tree/main/src/AirDropAnywhere.Core/MulticastDns) folder. 29 | 30 | ### HTTP API 31 | AirDrop is implemented as an HTTP API that ties into Kestrel using endpoint routing (i.e. no MVC, etc.). [AirDropRouteHandler](https://github.com/deanward81/AirDropAnywhere/tree/main/src/AirDropAnywhere.Core/AirDropRouteHandler.cs) implements the protocol. 32 | 33 | _TODO_ finish README! -------------------------------------------------------------------------------- /src/AirDropAnywhere.Core/AirDropEndpointRouteBuilderExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using AirDropAnywhere.Core; 3 | using Microsoft.AspNetCore.Routing; 4 | 5 | // ReSharper disable once CheckNamespace 6 | namespace Microsoft.AspNetCore.Builder 7 | { 8 | /// 9 | /// Provides extension methods for to add an AirDrop service. 10 | /// 11 | public static class AirDropEndpointRouteBuilderExtensions 12 | { 13 | /// 14 | /// Adds an AirDrop endpoint to the . 15 | /// 16 | /// The to add the AirDrop endpoint to. 17 | /// A convention routes for the AirDrop endpoint. 18 | public static IEndpointRouteBuilder MapAirDrop( 19 | this IEndpointRouteBuilder endpoints 20 | ) 21 | { 22 | if (endpoints == null) 23 | { 24 | throw new ArgumentNullException(nameof(endpoints)); 25 | } 26 | 27 | return MapAirDropCore(endpoints, null); 28 | } 29 | 30 | /// 31 | /// Adds an AirDrop endpoint to the with the specified options. 32 | /// 33 | /// The to add the AirDrop endpoints to. 34 | /// A used to configure AirDrop. 35 | /// A convention routes for the AirDrop endpoints. 36 | public static IEndpointRouteBuilder MapAirDrop( 37 | this IEndpointRouteBuilder endpoints, 38 | AirDropOptions options 39 | ) 40 | { 41 | if (endpoints == null) 42 | { 43 | throw new ArgumentNullException(nameof(endpoints)); 44 | } 45 | 46 | if (options == null) 47 | { 48 | throw new ArgumentNullException(nameof(options)); 49 | } 50 | 51 | return MapAirDropCore(endpoints, options); 52 | } 53 | 54 | private static IEndpointRouteBuilder MapAirDropCore(IEndpointRouteBuilder endpoints, AirDropOptions? options) 55 | { 56 | Utils.AssertPlatform(); 57 | Utils.AssertNetworkInterfaces(); 58 | 59 | if (endpoints.ServiceProvider.GetService(typeof(AirDropService)) == null) 60 | { 61 | throw new InvalidOperationException( 62 | "Unable to find services: make sure to call AddAirDrop in your ConfigureServices(...) method!" 63 | ); 64 | } 65 | 66 | endpoints.MapPost("Discover", ctx => AirDropRouteHandler.ExecuteAsync(ctx, r => r.DiscoverAsync())); 67 | endpoints.MapPost("Ask", ctx => AirDropRouteHandler.ExecuteAsync(ctx, r => r.AskAsync())); 68 | endpoints.MapPost("Upload", ctx => AirDropRouteHandler.ExecuteAsync(ctx, r => r.UploadAsync())); 69 | return endpoints; 70 | } 71 | } 72 | 73 | 74 | } 75 | -------------------------------------------------------------------------------- /restore.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Setup some colors to use. These need to work in fairly limited shells, like the Ubuntu Docker container where there are only 8 colors. 4 | # See if stdout is a terminal 5 | if [ -t 1 ] && command -v tput > /dev/null; then 6 | # see if it supports colors 7 | ncolors=$(tput colors) 8 | if [ -n "$ncolors" ] && [ $ncolors -ge 8 ]; then 9 | bold="$(tput bold || echo)" 10 | normal="$(tput sgr0 || echo)" 11 | black="$(tput setaf 0 || echo)" 12 | red="$(tput setaf 1 || echo)" 13 | green="$(tput setaf 2 || echo)" 14 | yellow="$(tput setaf 3 || echo)" 15 | blue="$(tput setaf 4 || echo)" 16 | magenta="$(tput setaf 5 || echo)" 17 | cyan="$(tput setaf 6 || echo)" 18 | white="$(tput setaf 7 || echo)" 19 | fi 20 | fi 21 | 22 | SCRIPT_PATH=$(cd "$(dirname "$0")"; pwd -P) 23 | USER_HOME=$(eval echo ~${SUDO_USER}) 24 | sayError() { 25 | printf "%b\n" "${red:-}Error: $1${normal:-}" 26 | } 27 | 28 | sayWarning() { 29 | printf "%b\n" "${yellow:-}Warning: $1${normal:-}" 30 | } 31 | 32 | sayInfo() { 33 | printf "%b\n" "${green:-}$1${normal:-}" 34 | } 35 | 36 | updateProfile() { 37 | PROFILE=$HOME/.bashrc 38 | if [ -f "$PROFILE" ]; then 39 | if ! grep -q "^export $1=.*$" "$PROFILE"; then 40 | echo "export $1=$2" >> $PROFILE 41 | else 42 | sed -i '' 's;^export '"$1"'=.*$;export '"$1"'='"$2"';' "$PROFILE" 43 | fi 44 | fi 45 | 46 | PROFILE=$HOME/.zshrc 47 | if [ ! -f "$PROFILE" ]; then 48 | # create a .zshrc if one doesn't exist 49 | touch "$PROFILE" 50 | chown "$SUDO_USER:staff" "$PROFILE" 51 | fi 52 | if ! grep -q "^export $1=.*$" "$PROFILE"; then 53 | echo "export $1=$2" >> $PROFILE 54 | else 55 | sed -i '' 's;^export '"$1"'=.*$;export '"$1"'='"$2"';' "$PROFILE" 56 | fi 57 | } 58 | 59 | installDotNet() { 60 | sayInfo "Installing .NET SDK $DOTNET_VERSION..." 61 | # gotta download and install from scratch 62 | chmod 755 "$SCRIPT_PATH/build/dotnet-install.sh" 63 | "$SCRIPT_PATH/build/dotnet-install.sh" --version $DOTNET_VERSION --no-path --install-dir "$DOTNET_ROOT" 64 | if [ $? -ne 0 ]; then 65 | sayError "Error installing .NET SDK" 66 | exit 1 67 | fi 68 | 69 | sayInfo "Installed .NET SDK $DOTNET_VERSION!" 70 | } 71 | 72 | # make sure we're running elevated 73 | if [ $EUID -ne 0 ]; then 74 | sayWarning "Script must be run in an elevated context. Running using sudo" 75 | sudo env HOME=$USER_HOME $0 76 | exit 0 77 | fi 78 | 79 | # check if JQ is installed 80 | if [ ! -x "$(which jq)" ]; then 81 | sayError "jq is not installed. See https://stedolan.github.io/jq/download/" 82 | exit 1 83 | fi 84 | 85 | # check if dotnet is installed 86 | DOTNET_EXE=$(which dotnet) 87 | DOTNET_VERSION=$(cat global.json | jq --raw-output '.sdk.version') 88 | export DOTNET_ROOT=/usr/local/share/dotnet 89 | export PATH=$PATH:$DOTNET_ROOT 90 | if [ ! -x "$DOTNET_EXE" ]; then 91 | installDotNet 92 | else 93 | # make sure it's the latest build 94 | DOTNET_SDKS=$(dotnet --list-sdks | grep "$DOTNET_VERSION") 95 | if [ -z "$DOTNET_SDKS" ]; then 96 | sayWarning ".NET Core SDK $DOTNET_VERSION not installed" 97 | installDotNet 98 | fi 99 | fi 100 | 101 | # update the path 102 | updateProfile "DOTNET_ROOT" "$DOTNET_ROOT" 103 | updateProfile "PATH" "\$PATH:\$DOTNET_ROOT" -------------------------------------------------------------------------------- /src/AirDropAnywhere.Cli/Hubs/PolymorphicJsonConverter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Collections.Immutable; 4 | using System.Linq; 5 | using System.Reflection; 6 | using System.Text.Json; 7 | using System.Text.Json.Serialization; 8 | 9 | namespace AirDropAnywhere.Cli.Hubs 10 | { 11 | /// 12 | /// Converter that ensures that the provided types are serialized/deserialized 13 | /// in a way that maintains type information over the wire. 14 | /// 15 | internal class PolymorphicJsonConverter : JsonConverter 16 | { 17 | private readonly ImmutableDictionary _forwardMappings; 18 | private readonly ImmutableDictionary _reverseMappings; 19 | 20 | public PolymorphicJsonConverter( 21 | IEnumerable<(string Name, Type Type)> typeMappings 22 | ) 23 | { 24 | if (typeMappings == null) 25 | { 26 | throw new ArgumentNullException(nameof(typeMappings)); 27 | } 28 | 29 | var forwardMappings = ImmutableDictionary.CreateBuilder(); 30 | var reverseMappings = ImmutableDictionary.CreateBuilder(); 31 | foreach (var typeMapping in typeMappings) 32 | { 33 | forwardMappings[typeMapping.Name] = typeMapping.Type; 34 | reverseMappings[typeMapping.Type] = typeMapping.Name; 35 | } 36 | 37 | _forwardMappings = forwardMappings.ToImmutableDictionary(); 38 | _reverseMappings = reverseMappings.ToImmutableDictionary(); 39 | } 40 | 41 | public override bool CanConvert(Type type) => _reverseMappings.ContainsKey(type); 42 | 43 | public override object? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) 44 | { 45 | if (reader.TokenType != JsonTokenType.StartObject) 46 | { 47 | throw new JsonException(); 48 | } 49 | 50 | if (!reader.Read() || reader.TokenType != JsonTokenType.PropertyName) 51 | { 52 | throw new JsonException(); 53 | } 54 | 55 | var typeName = reader.GetString(); 56 | if (typeName == null || !_forwardMappings.TryGetValue(typeName, out var type)) 57 | { 58 | throw new JsonException($"Unsupported type '{typeName}'"); 59 | } 60 | 61 | var value = JsonSerializer.Deserialize(ref reader, type)!; 62 | if (!reader.Read() || reader.TokenType != JsonTokenType.EndObject) 63 | { 64 | throw new JsonException(); 65 | } 66 | 67 | return value; 68 | } 69 | 70 | public override void Write(Utf8JsonWriter writer, object value, JsonSerializerOptions options) 71 | { 72 | var type = value.GetType(); 73 | if (!_reverseMappings.TryGetValue(type, out var typeName)) 74 | { 75 | throw new JsonException($"Unsupported type '{type}'"); 76 | } 77 | 78 | writer.WriteStartObject(); 79 | writer.WritePropertyName(typeName); 80 | JsonSerializer.Serialize(writer, value); 81 | writer.WriteEndObject(); 82 | } 83 | 84 | public static PolymorphicJsonConverter Create(Type rootType) => 85 | new( 86 | rootType 87 | .GetCustomAttributes() 88 | .Select(attr => (attr.Name, attr.Type)) 89 | .Concat(new[] { ("root", rootType) }) 90 | ); 91 | } 92 | } -------------------------------------------------------------------------------- /src/AirDropAnywhere.Core/MulticastDns/UdpSocketExtensions.cs: -------------------------------------------------------------------------------- 1 | // MIT license 2 | // https://github.com/enclave-networks/research.udp-perf/tree/main/Enclave.UdpPerf 3 | 4 | using Microsoft.Extensions.ObjectPool; 5 | using System; 6 | using System.Net; 7 | using System.Net.Sockets; 8 | using System.Runtime.InteropServices; 9 | using System.Threading.Tasks; 10 | 11 | // ReSharper disable once CheckNamespace 12 | namespace Enclave.UdpPerf 13 | { 14 | internal static class UdpSocketExtensions 15 | { 16 | // This pool of socket events means that we don't need to keep allocating the SocketEventArgs. 17 | // The main reason we want to pool these (apart from just reducing allocations), is that, on windows at least, within the depths 18 | // of the underlying SocketAsyncEventArgs implementation, each one holds an instance of PreAllocatedNativeOverlapped, 19 | // an IOCP-specific object which is VERY expensive to allocate each time. 20 | private static readonly ObjectPool _socketEventPool = ObjectPool.Create(); 21 | 22 | /// 23 | /// Send a block of data to a specified destination, and complete asynchronously. 24 | /// 25 | /// The socket to send on. 26 | /// The destination of the data. 27 | /// The data buffer itself. 28 | /// The number of bytes transferred. 29 | public static async ValueTask SendToAsync(this Socket socket, EndPoint destination, ReadOnlyMemory data) 30 | { 31 | // Get an async argument from the socket event pool. 32 | var asyncArgs = _socketEventPool.Get(); 33 | 34 | asyncArgs.RemoteEndPoint = destination; 35 | asyncArgs.SetBuffer(MemoryMarshal.AsMemory(data)); 36 | 37 | try 38 | { 39 | return await asyncArgs.DoSendToAsync(socket); 40 | } 41 | finally 42 | { 43 | _socketEventPool.Return(asyncArgs); 44 | } 45 | } 46 | 47 | /// 48 | /// Asynchronously receive a block of data, getting the amount of data received, and the remote endpoint that 49 | /// sent it. 50 | /// 51 | /// The socket to send on. 52 | /// to send data to. 53 | /// The buffer to place data in. 54 | /// The number of bytes transferred. 55 | public static async ValueTask ReceiveFromAsync(this Socket socket, IPEndPoint endpoint, Memory buffer) 56 | { 57 | // Get an async argument from the socket event pool. 58 | var asyncArgs = _socketEventPool.Get(); 59 | 60 | asyncArgs.RemoteEndPoint = endpoint; 61 | asyncArgs.SetBuffer(buffer); 62 | 63 | try 64 | { 65 | var recvdBytes = await asyncArgs.ReceiveFromAsync(socket); 66 | 67 | return new SocketReceiveResult(asyncArgs.RemoteEndPoint, asyncArgs.ReceiveMessageFromPacketInfo, recvdBytes); 68 | 69 | } 70 | finally 71 | { 72 | _socketEventPool.Return(asyncArgs); 73 | } 74 | } 75 | 76 | public readonly struct SocketReceiveResult 77 | { 78 | public SocketReceiveResult( 79 | EndPoint endpoint, 80 | IPPacketInformation packetInformation, 81 | int receivedBytes 82 | ) 83 | { 84 | Endpoint = endpoint; 85 | PacketInformation = packetInformation; 86 | ReceivedBytes = receivedBytes; 87 | } 88 | 89 | public EndPoint Endpoint { get; } 90 | public IPPacketInformation PacketInformation { get; } 91 | public int ReceivedBytes { get; } 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /tests/AirDropAnywhere.Tests/CpioArchiveReaderTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | using AirDropAnywhere.Core.Compression; 6 | using Xunit; 7 | using Xunit.Abstractions; 8 | 9 | namespace AirDropAnywhere.Tests 10 | { 11 | public class CpioArchiveReaderTests : IDisposable 12 | { 13 | private readonly string _outputPath; 14 | private readonly ITestOutputHelper _log; 15 | 16 | public CpioArchiveReaderTests(ITestOutputHelper log) 17 | { 18 | _log = log ?? throw new ArgumentNullException(nameof(log)); 19 | _outputPath = Path.Join( 20 | Environment.CurrentDirectory, "extracted", DateTime.UtcNow.ToString("yyyyMMddTHHmm.fff") 21 | ); 22 | 23 | if (!Directory.Exists(_outputPath)) 24 | { 25 | Directory.CreateDirectory(_outputPath); 26 | } 27 | 28 | _log.WriteLine("Extracting files to {0}", _outputPath); 29 | } 30 | 31 | [Fact] 32 | public async Task ExtractSingleFile() 33 | { 34 | await using var fileStream = File.OpenRead("test.single.cpio"); 35 | await using var cpioArchiveReader = CpioArchiveReader.Create(fileStream); 36 | await cpioArchiveReader.ExtractAsync(_outputPath); 37 | 38 | // we're expecting just one file of length 33 bytes 39 | var directoryInfo = new DirectoryInfo(_outputPath); 40 | var files = directoryInfo.GetFiles(); 41 | Assert.Single(files, f => f.Length == 33); 42 | } 43 | 44 | [Fact] 45 | public async Task ExtractMultipleFiles() 46 | { 47 | await using var fileStream = File.OpenRead("test.multiple.cpio"); 48 | await using var cpioArchiveReader = CpioArchiveReader.Create(fileStream); 49 | await cpioArchiveReader.ExtractAsync(_outputPath); 50 | 51 | // we're expecting 100 files, each of length 1024 52 | var directoryInfo = new DirectoryInfo(_outputPath); 53 | var files = directoryInfo.GetFiles(); 54 | Assert.Equal(100, files.Length); 55 | Assert.True(files.All(f => f.Length == 1024)); 56 | } 57 | 58 | [Fact] 59 | public async Task ExtractLargeFiles() 60 | { 61 | await using var fileStream = File.OpenRead("test.large.cpio"); 62 | await using var cpioArchiveReader = CpioArchiveReader.Create(fileStream); 63 | await cpioArchiveReader.ExtractAsync(_outputPath); 64 | 65 | // we're expecting 5 files, each of length 10240 66 | var directoryInfo = new DirectoryInfo(_outputPath); 67 | var files = directoryInfo.GetFiles(); 68 | Assert.Equal(5, files.Length); 69 | Assert.True(files.All(f => f.Length == 10240)); 70 | } 71 | 72 | [Fact] 73 | public async Task ExtractNestedFiles() 74 | { 75 | 76 | string GetNormalizedPath(FileInfo fileInfo) => Path.GetRelativePath(_outputPath, fileInfo.FullName).Replace('\\', '/'); 77 | 78 | await using var fileStream = File.OpenRead("test.nested.cpio"); 79 | await using var cpioArchiveReader = CpioArchiveReader.Create(fileStream); 80 | await cpioArchiveReader.ExtractAsync(_outputPath); 81 | 82 | // we're expecting 3 files, in a specific directory structure 83 | var directoryInfo = new DirectoryInfo(_outputPath); 84 | var files = directoryInfo.GetFiles("*.*", SearchOption.AllDirectories).OrderBy(x => x.FullName).ToArray(); 85 | Assert.Equal(3, files.Length); 86 | Assert.Collection( 87 | files, 88 | f => Assert.Equal("test1/test.txt", GetNormalizedPath(f)), 89 | f => Assert.Equal("test2/test.log", GetNormalizedPath(f)), 90 | f => Assert.Equal("test3/test4/test.csv", GetNormalizedPath(f)) 91 | ); 92 | } 93 | 94 | public void Dispose() 95 | { 96 | if (Directory.Exists(_outputPath)) 97 | { 98 | Directory.Delete(_outputPath, true); 99 | } 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /AirDropAnywhere.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 16 4 | VisualStudioVersion = 16.6.30114.105 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AirDropAnywhere.Core", "src\AirDropAnywhere.Core\AirDropAnywhere.Core.csproj", "{CF312130-E1A4-4826-8CEE-47C114072B16}" 7 | EndProject 8 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AirDropAnywhere.Cli", "src\AirDropAnywhere.Cli\AirDropAnywhere.Cli.csproj", "{C8951036-357C-471A-A52B-FDEC9E0A970E}" 9 | EndProject 10 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{DC218991-B117-4703-B51C-29A61DC4C8DF}" 11 | EndProject 12 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{B6A7851D-A7F6-4829-B92F-737EB598B8DC}" 13 | EndProject 14 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AirDropAnywhere.Tests", "tests\AirDropAnywhere.Tests\AirDropAnywhere.Tests.csproj", "{33D345D6-30F7-4B27-B229-E9B507D6D249}" 15 | EndProject 16 | Global 17 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 18 | Debug|Any CPU = Debug|Any CPU 19 | Debug|x64 = Debug|x64 20 | Debug|x86 = Debug|x86 21 | Release|Any CPU = Release|Any CPU 22 | Release|x64 = Release|x64 23 | Release|x86 = Release|x86 24 | EndGlobalSection 25 | GlobalSection(SolutionProperties) = preSolution 26 | HideSolutionNode = FALSE 27 | EndGlobalSection 28 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 29 | {CF312130-E1A4-4826-8CEE-47C114072B16}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 30 | {CF312130-E1A4-4826-8CEE-47C114072B16}.Debug|Any CPU.Build.0 = Debug|Any CPU 31 | {CF312130-E1A4-4826-8CEE-47C114072B16}.Debug|x64.ActiveCfg = Debug|Any CPU 32 | {CF312130-E1A4-4826-8CEE-47C114072B16}.Debug|x64.Build.0 = Debug|Any CPU 33 | {CF312130-E1A4-4826-8CEE-47C114072B16}.Debug|x86.ActiveCfg = Debug|Any CPU 34 | {CF312130-E1A4-4826-8CEE-47C114072B16}.Debug|x86.Build.0 = Debug|Any CPU 35 | {CF312130-E1A4-4826-8CEE-47C114072B16}.Release|Any CPU.ActiveCfg = Release|Any CPU 36 | {CF312130-E1A4-4826-8CEE-47C114072B16}.Release|Any CPU.Build.0 = Release|Any CPU 37 | {CF312130-E1A4-4826-8CEE-47C114072B16}.Release|x64.ActiveCfg = Release|Any CPU 38 | {CF312130-E1A4-4826-8CEE-47C114072B16}.Release|x64.Build.0 = Release|Any CPU 39 | {CF312130-E1A4-4826-8CEE-47C114072B16}.Release|x86.ActiveCfg = Release|Any CPU 40 | {CF312130-E1A4-4826-8CEE-47C114072B16}.Release|x86.Build.0 = Release|Any CPU 41 | {C8951036-357C-471A-A52B-FDEC9E0A970E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 42 | {C8951036-357C-471A-A52B-FDEC9E0A970E}.Debug|Any CPU.Build.0 = Debug|Any CPU 43 | {C8951036-357C-471A-A52B-FDEC9E0A970E}.Debug|x64.ActiveCfg = Debug|Any CPU 44 | {C8951036-357C-471A-A52B-FDEC9E0A970E}.Debug|x64.Build.0 = Debug|Any CPU 45 | {C8951036-357C-471A-A52B-FDEC9E0A970E}.Debug|x86.ActiveCfg = Debug|Any CPU 46 | {C8951036-357C-471A-A52B-FDEC9E0A970E}.Debug|x86.Build.0 = Debug|Any CPU 47 | {C8951036-357C-471A-A52B-FDEC9E0A970E}.Release|Any CPU.ActiveCfg = Release|Any CPU 48 | {C8951036-357C-471A-A52B-FDEC9E0A970E}.Release|Any CPU.Build.0 = Release|Any CPU 49 | {C8951036-357C-471A-A52B-FDEC9E0A970E}.Release|x64.ActiveCfg = Release|Any CPU 50 | {C8951036-357C-471A-A52B-FDEC9E0A970E}.Release|x64.Build.0 = Release|Any CPU 51 | {C8951036-357C-471A-A52B-FDEC9E0A970E}.Release|x86.ActiveCfg = Release|Any CPU 52 | {C8951036-357C-471A-A52B-FDEC9E0A970E}.Release|x86.Build.0 = Release|Any CPU 53 | {33D345D6-30F7-4B27-B229-E9B507D6D249}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 54 | {33D345D6-30F7-4B27-B229-E9B507D6D249}.Debug|Any CPU.Build.0 = Debug|Any CPU 55 | {33D345D6-30F7-4B27-B229-E9B507D6D249}.Debug|x64.ActiveCfg = Debug|Any CPU 56 | {33D345D6-30F7-4B27-B229-E9B507D6D249}.Debug|x64.Build.0 = Debug|Any CPU 57 | {33D345D6-30F7-4B27-B229-E9B507D6D249}.Debug|x86.ActiveCfg = Debug|Any CPU 58 | {33D345D6-30F7-4B27-B229-E9B507D6D249}.Debug|x86.Build.0 = Debug|Any CPU 59 | {33D345D6-30F7-4B27-B229-E9B507D6D249}.Release|Any CPU.ActiveCfg = Release|Any CPU 60 | {33D345D6-30F7-4B27-B229-E9B507D6D249}.Release|Any CPU.Build.0 = Release|Any CPU 61 | {33D345D6-30F7-4B27-B229-E9B507D6D249}.Release|x64.ActiveCfg = Release|Any CPU 62 | {33D345D6-30F7-4B27-B229-E9B507D6D249}.Release|x64.Build.0 = Release|Any CPU 63 | {33D345D6-30F7-4B27-B229-E9B507D6D249}.Release|x86.ActiveCfg = Release|Any CPU 64 | {33D345D6-30F7-4B27-B229-E9B507D6D249}.Release|x86.Build.0 = Release|Any CPU 65 | EndGlobalSection 66 | GlobalSection(NestedProjects) = preSolution 67 | {C8951036-357C-471A-A52B-FDEC9E0A970E} = {DC218991-B117-4703-B51C-29A61DC4C8DF} 68 | {CF312130-E1A4-4826-8CEE-47C114072B16} = {DC218991-B117-4703-B51C-29A61DC4C8DF} 69 | {33D345D6-30F7-4B27-B229-E9B507D6D249} = {B6A7851D-A7F6-4829-B92F-737EB598B8DC} 70 | EndGlobalSection 71 | EndGlobal 72 | -------------------------------------------------------------------------------- /src/AirDropAnywhere.Core/MulticastDns/MulticastDnsService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Collections.Immutable; 4 | using System.Linq; 5 | using System.Net; 6 | using Makaretu.Dns; 7 | 8 | namespace AirDropAnywhere.Core.MulticastDns 9 | { 10 | public class MulticastDnsService 11 | { 12 | public static readonly DomainName Root = new("local"); 13 | public static readonly DomainName Discovery = new("_services._dns-sd._udp.local"); 14 | public static readonly TimeSpan DefaultTTL = TimeSpan.FromMinutes(5); 15 | 16 | private MulticastDnsService( 17 | DomainName serviceName, 18 | DomainName instanceName, 19 | DomainName hostName, 20 | ImmutableArray endpoints, 21 | ImmutableDictionary properties 22 | ) 23 | { 24 | if (endpoints.IsDefaultOrEmpty) 25 | { 26 | throw new ArgumentException("Endpoints are required", nameof(endpoints)); 27 | } 28 | 29 | ServiceName = serviceName ?? throw new ArgumentNullException(nameof(serviceName)); 30 | InstanceName = instanceName ?? throw new ArgumentNullException(nameof(instanceName)); 31 | HostName = hostName ?? throw new ArgumentNullException(nameof(hostName)); 32 | EndPoints = endpoints; 33 | Properties = properties ?? throw new ArgumentNullException(nameof(properties)); 34 | QualifiedServiceName = DomainName.Join(ServiceName, Root); 35 | QualifiedInstanceName = DomainName.Join(InstanceName, QualifiedServiceName); 36 | } 37 | 38 | public DomainName ServiceName { get; } 39 | public DomainName InstanceName { get; } 40 | public DomainName HostName { get; } 41 | public DomainName QualifiedServiceName { get; } 42 | public DomainName QualifiedInstanceName { get; } 43 | public ImmutableArray EndPoints { get; } 44 | public ImmutableDictionary Properties { get; } 45 | 46 | private Message? _message; 47 | 48 | public Message ToMessage() 49 | { 50 | Message Create() 51 | { 52 | var hostName = DomainName.Join(HostName, Root); 53 | var message = new Message 54 | { 55 | QR = true 56 | }; 57 | 58 | // grab distinct ports 59 | var ports = EndPoints.Select(x => x.Port).Distinct(); 60 | foreach (var port in ports) 61 | { 62 | message.Answers.Add( 63 | new SRVRecord 64 | { 65 | Name = QualifiedInstanceName, 66 | Target = hostName, 67 | TTL = DefaultTTL, 68 | Port = (ushort) port 69 | }); 70 | } 71 | 72 | foreach (var endpoint in EndPoints) 73 | { 74 | var addressRecord = AddressRecord.Create(hostName, endpoint.Address); 75 | addressRecord.TTL = DefaultTTL; 76 | message.Answers.Add(addressRecord); 77 | } 78 | 79 | message.Answers.Add( 80 | new TXTRecord 81 | { 82 | Name = QualifiedInstanceName, 83 | Strings = Properties.Select(kv => $"{kv.Key}={kv.Value}").ToList(), 84 | TTL = DefaultTTL 85 | } 86 | ); 87 | return message; 88 | } 89 | 90 | 91 | return _message ??= Create(); 92 | } 93 | 94 | internal class Builder 95 | { 96 | #pragma warning disable 8618 97 | private DomainName _serviceName; 98 | private DomainName _instanceName; 99 | private DomainName _hostName; 100 | #pragma warning restore 8618 101 | 102 | private readonly ImmutableArray.Builder _endpoints = ImmutableArray.CreateBuilder(); 103 | private readonly ImmutableDictionary.Builder _properties = ImmutableDictionary.CreateBuilder(); 104 | 105 | public Builder SetNames(DomainName serviceName, DomainName instanceName, DomainName hostName) 106 | { 107 | _serviceName = serviceName ?? throw new ArgumentNullException(nameof(serviceName)); 108 | _instanceName = instanceName ?? throw new ArgumentNullException(nameof(instanceName)); 109 | _hostName = hostName ?? throw new ArgumentNullException(nameof(hostName)); 110 | return this; 111 | } 112 | 113 | public Builder AddEndpoint(IPEndPoint ipEndPoint) 114 | { 115 | _endpoints.Add(ipEndPoint); 116 | return this; 117 | } 118 | 119 | public Builder AddEndpoints(IEnumerable ipEndPoints) 120 | { 121 | _endpoints.AddRange(ipEndPoints); 122 | return this; 123 | } 124 | 125 | public Builder AddProperty(string key, string value) 126 | { 127 | _properties.Add(key, value); 128 | return this; 129 | } 130 | 131 | public MulticastDnsService Build() => new MulticastDnsService( 132 | _serviceName, 133 | _instanceName, 134 | _hostName, 135 | _endpoints.ToImmutable(), 136 | _properties.ToImmutable() 137 | ); 138 | } 139 | } 140 | } -------------------------------------------------------------------------------- /src/AirDropAnywhere.Core/Certificates/CertificateManager.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Security.Cryptography; 5 | using System.Security.Cryptography.X509Certificates; 6 | using System.Text; 7 | 8 | namespace AirDropAnywhere.Core.Certificates 9 | { 10 | /// 11 | /// Generates a self-signed certificate for the AirDrop HTTPS endpoint. 12 | /// 13 | public static class CertificateManager 14 | { 15 | /// 16 | /// This OID is in the badly-documented "private" range based upon this ServerFault 17 | /// answer: https://serverfault.com/a/861475. We should be fine to use this 18 | /// as it's only used for our own ephemeral self-signed certificate. 19 | /// 20 | private const string AirDropHttpsOid = "1.3.9999.1.1"; 21 | private const string AirDropHttpsOidFriendlyName = "AirDrop Anywhere HTTPS certificate"; 22 | 23 | private const string ServerAuthenticationEnhancedKeyUsageOid = "1.3.6.1.5.5.7.3.1"; 24 | private const string ServerAuthenticationEnhancedKeyUsageOidFriendlyName = "Server Authentication"; 25 | private const string AirDropHttpsDnsName = "airdrop.local"; 26 | private const string AirDropHttpsDistinguishedName = "CN=" + AirDropHttpsDnsName; 27 | 28 | /// 29 | /// Creates a self-signed certificate suitable for serving requests over 30 | /// AirDrop's HTTPS endpoint 31 | /// 32 | /// 33 | /// An representing the self-signed certificate. 34 | /// 35 | public static X509Certificate2 Create() 36 | { 37 | // TODO: make this CreateOrGet so we don't keep generating a new certificate 38 | // everytime the service starts. We can store the created certificate in the 39 | // user's private X509 store 40 | return CreateCertificate(DateTimeOffset.Now, DateTimeOffset.Now.AddYears(1)); 41 | } 42 | 43 | /// 44 | /// This method is largely lifted from the code used to generate self-signed 45 | /// X509 certificates in the .NET SDK. See https://github.com/dotnet/aspnetcore/blob/main/src/Shared/CertificateGeneration/CertificateManager.cs 46 | /// 47 | private static X509Certificate2 CreateCertificate(DateTimeOffset notBefore, DateTimeOffset notAfter) 48 | { 49 | var subject = new X500DistinguishedName(AirDropHttpsDistinguishedName); 50 | var extensions = new List(); 51 | var sanBuilder = new SubjectAlternativeNameBuilder(); 52 | sanBuilder.AddDnsName(AirDropHttpsDnsName); 53 | 54 | var keyUsage = new X509KeyUsageExtension(X509KeyUsageFlags.KeyEncipherment | X509KeyUsageFlags.DigitalSignature, critical: true); 55 | var enhancedKeyUsage = new X509EnhancedKeyUsageExtension( 56 | new OidCollection 57 | { 58 | new( 59 | ServerAuthenticationEnhancedKeyUsageOid, 60 | ServerAuthenticationEnhancedKeyUsageOidFriendlyName 61 | ) 62 | }, 63 | critical: true); 64 | 65 | var basicConstraints = new X509BasicConstraintsExtension( 66 | certificateAuthority: false, 67 | hasPathLengthConstraint: false, 68 | pathLengthConstraint: 0, 69 | critical: true 70 | ); 71 | 72 | var bytePayload = Encoding.ASCII.GetBytes(AirDropHttpsOidFriendlyName); 73 | var aspNetHttpsExtension = new X509Extension( 74 | new AsnEncodedData( 75 | new Oid(AirDropHttpsOid, AirDropHttpsOidFriendlyName), 76 | bytePayload), 77 | critical: false); 78 | 79 | extensions.Add(basicConstraints); 80 | extensions.Add(keyUsage); 81 | extensions.Add(enhancedKeyUsage); 82 | extensions.Add(sanBuilder.Build(critical: true)); 83 | extensions.Add(aspNetHttpsExtension); 84 | 85 | var certificate = CreateSelfSignedCertificate(subject, extensions, notBefore, notAfter); 86 | return certificate; 87 | } 88 | 89 | private static X509Certificate2 CreateSelfSignedCertificate( 90 | X500DistinguishedName subject, 91 | IEnumerable extensions, 92 | DateTimeOffset notBefore, 93 | DateTimeOffset notAfter 94 | ) 95 | { 96 | using var key = CreateKeyMaterial(4096); 97 | 98 | var request = new CertificateRequest(subject, key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); 99 | foreach (var extension in extensions) 100 | { 101 | request.CertificateExtensions.Add(extension); 102 | } 103 | 104 | var result = request.CreateSelfSigned(notBefore, notAfter); 105 | return result; 106 | 107 | RSA CreateKeyMaterial(int minimumKeySize) 108 | { 109 | var rsa = RSA.Create(minimumKeySize); 110 | if (rsa.KeySize < minimumKeySize) 111 | { 112 | throw new InvalidOperationException($"Failed to create a key with a size of {minimumKeySize} bits"); 113 | } 114 | 115 | return rsa; 116 | } 117 | } 118 | 119 | private static bool IsValidCertificate(X509Certificate2 certificate, DateTimeOffset currentDate) => 120 | certificate.NotBefore <= currentDate && 121 | currentDate <= certificate.NotAfter; 122 | 123 | private static bool HasOid(X509Certificate2 certificate, string oid) => 124 | certificate.Extensions 125 | .Any(e => string.Equals(oid, e.Oid?.Value, StringComparison.Ordinal)); 126 | 127 | } 128 | } -------------------------------------------------------------------------------- /src/AirDropAnywhere.Core/Utils.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Net.Http; 4 | using System.Net.NetworkInformation; 5 | using System.Net.Sockets; 6 | using System.Runtime.InteropServices; 7 | using System.Security.Cryptography; 8 | using System.Threading.Tasks; 9 | using AirDropAnywhere.Core.Serialization; 10 | using Microsoft.AspNetCore.Http; 11 | 12 | namespace AirDropAnywhere.Core 13 | { 14 | /// 15 | /// Internal utility functions. 16 | /// 17 | internal static class Utils 18 | { 19 | /// 20 | /// Asserts that we're running on a supported platform. 21 | /// 22 | public static void AssertPlatform() 23 | { 24 | // TODO: support linux 25 | if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX) && !RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) 26 | { 27 | throw new InvalidOperationException( 28 | "AirDropAnywhere is currently only supported on MacOS or Linux because it needs support for either the AWDL or OWL protocol." 29 | ); 30 | } 31 | } 32 | 33 | /// 34 | /// Asserts that the system has an AWDL network interface. 35 | /// 36 | public static void AssertNetworkInterfaces() 37 | { 38 | var hasAwdlInterface = NetworkInterface.GetAllNetworkInterfaces().Any(i => i.IsAwdlInterface()); 39 | if (!hasAwdlInterface) 40 | { 41 | throw new InvalidOperationException( 42 | "No awdl0 interface found on this system. AirDrop Anywhere is currently only supported on systems that support the AWDL or OWL protocol." 43 | ); 44 | } 45 | } 46 | 47 | /// 48 | /// Determines if the specified network interface is used for AWDL. 49 | /// 50 | public static bool IsAwdlInterface(this NetworkInterface networkInterface) => networkInterface.Id == "awdl0"; 51 | 52 | private static readonly ReadOnlyMemory _trueSocketValue = BitConverter.GetBytes(1); 53 | 54 | public static void SetReuseAddressSocketOption(this Socket socket) 55 | { 56 | socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, 1); 57 | 58 | if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) 59 | { 60 | return; 61 | } 62 | 63 | const int SO_REUSE_ADDR = 0x4; 64 | socket.SetRawSocketOption( 65 | (int)SocketOptionLevel.Socket, SO_REUSE_ADDR, _trueSocketValue.Span 66 | ); 67 | } 68 | 69 | /// 70 | /// Configures a socket so that it can communicate over an AWDL interface. 71 | /// 72 | public static void SetAwdlSocketOption(this Socket socket) 73 | { 74 | if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) 75 | { 76 | return; 77 | } 78 | 79 | // from Apple header files: 80 | // sys/socket.h: #define SO_RECV_ANYIF 0x1104 81 | // ReSharper disable once InconsistentNaming 82 | // ReSharper disable once IdentifierTypo 83 | const int SO_RECV_ANYIF = 0x1104; 84 | socket.SetRawSocketOption( 85 | (int)SocketOptionLevel.Socket, SO_RECV_ANYIF, _trueSocketValue.Span 86 | ); 87 | } 88 | 89 | /// 90 | /// Writes an object of the specified type from the HTTP request using Apple's plist binary format. 91 | /// 92 | public static ValueTask ReadFromPropertyListAsync(this HttpRequest request) where T : class, new() 93 | { 94 | if (!request.ContentLength.HasValue || request.ContentLength > PropertyListSerializer.MaxPropertyListLength) 95 | { 96 | throw new HttpRequestException("Content length is too long."); 97 | } 98 | 99 | return PropertyListSerializer.DeserializeAsync(request.Body); 100 | } 101 | 102 | /// 103 | /// Writes the specified object to the HTTP response in Apple's plist binary format. 104 | /// 105 | public static ValueTask WriteAsPropertyListAsync(this HttpResponse response, T obj) where T : class 106 | { 107 | if (obj == null) 108 | { 109 | throw new ArgumentNullException(nameof(obj)); 110 | } 111 | 112 | response.ContentType = "application/octet-stream"; 113 | return PropertyListSerializer.SerializeAsync(obj, response.Body); 114 | } 115 | 116 | /// 117 | /// Generates a 12 character random string. 118 | /// 119 | public static string GetRandomString() 120 | { 121 | const string charset = "abcdefghijklmnopqrstuvwxyz0123456789"; 122 | Span bytes = stackalloc byte[12]; 123 | Span chars = stackalloc char[12]; 124 | RandomNumberGenerator.Fill(bytes); 125 | 126 | for (var i = 0; i < bytes.Length; i++) 127 | { 128 | chars[i] = charset[bytes[i] % (charset.Length)]; 129 | } 130 | 131 | return new string(chars); 132 | } 133 | 134 | private static bool TryGetOctalDigit(byte c, out int value) 135 | { 136 | value = c - '0'; 137 | return value <= 7; 138 | } 139 | 140 | /// 141 | /// Parses an octal string to a . 142 | /// 143 | /// 144 | /// Ripped off a little from .NET Core code 145 | /// https://source.dot.net/#System.Private.CoreLib/ParseNumbers.cs,574 146 | /// 147 | public static bool TryParseOctalToUInt32(ReadOnlySpan s, out uint value) 148 | { 149 | if (s.Length == 0) 150 | { 151 | value = default; 152 | return false; 153 | } 154 | 155 | uint result = 0; 156 | const uint maxValue = uint.MaxValue / 8; 157 | 158 | var i = 0; 159 | // Read all of the digits and convert to a number 160 | while (i < s.Length && TryGetOctalDigit(s[i], out var digit)) 161 | { 162 | if (result > maxValue) 163 | { 164 | value = default; 165 | return false; 166 | } 167 | 168 | uint temp = result * (uint) 8 + (uint) digit; 169 | if (temp > maxValue) 170 | { 171 | value = default; 172 | return false; 173 | } 174 | result = temp; 175 | i++; 176 | } 177 | 178 | if (i != s.Length) 179 | { 180 | value = default; 181 | return false; 182 | } 183 | 184 | value = result; 185 | return true; 186 | } 187 | 188 | } 189 | } -------------------------------------------------------------------------------- /src/AirDropAnywhere.Core/AirDropRouteHandler.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.IO.Compression; 4 | using System.Threading.Tasks; 5 | using AirDropAnywhere.Core.Compression; 6 | using AirDropAnywhere.Core.Protocol; 7 | using Microsoft.AspNetCore.Http; 8 | using Microsoft.Extensions.DependencyInjection; 9 | using Microsoft.Extensions.Logging; 10 | using Microsoft.Extensions.Options; 11 | 12 | namespace AirDropAnywhere.Core 13 | { 14 | public class AirDropRouteHandler 15 | { 16 | private readonly ILogger _logger; 17 | private readonly AirDropOptions _options; 18 | private readonly AirDropPeer _peer; 19 | private readonly HttpContext _ctx; 20 | 21 | public AirDropRouteHandler( 22 | HttpContext ctx, 23 | AirDropPeer peer, 24 | AirDropOptions options, 25 | ILogger logger 26 | ) 27 | { 28 | _ctx = ctx ?? throw new ArgumentNullException(nameof(ctx)); 29 | _peer = peer ?? throw new ArgumentNullException(nameof(peer)); 30 | _options = options ?? throw new ArgumentNullException(nameof(options)); 31 | _logger = logger ?? throw new ArgumentNullException(nameof(logger)); 32 | } 33 | 34 | private HttpRequest Request => _ctx.Request; 35 | private HttpResponse Response => _ctx.Response; 36 | 37 | public static Task ExecuteAsync(HttpContext ctx, Func executor) 38 | { 39 | // when an AirDrop Anywhere "client" connects to the proxy 40 | // it registers itself as a "channel". When handling registration 41 | // of a channel the underlying code assigns it a unique identifier 42 | // which is used as the host header when using the HTTP API. This unique 43 | // identifier is advertised as a SRV record via mDNS. 44 | // 45 | // Here we attempt to map the host header to its underlying channel 46 | // If none is found then we 404, otherwise we new up the route handler, 47 | // and pass the services and channel that the HTTP API should be using 48 | // to handle the to and fro of the AirDrop protocol 49 | var service = ctx.RequestServices.GetRequiredService(); 50 | var logger = ctx.RequestServices.GetRequiredService>(); 51 | var options = ctx.RequestServices.GetRequiredService>(); 52 | var hostSpan = ctx.Request.Host.Host.AsSpan(); 53 | var firstPartIndex = hostSpan.IndexOf('.'); 54 | if (firstPartIndex == -1) 55 | { 56 | return NotFound(); 57 | } 58 | 59 | var channelId = hostSpan[..firstPartIndex]; 60 | if (!service.TryGetPeer(channelId.ToString(), out var peer)) 61 | { 62 | return NotFound(); 63 | } 64 | 65 | var handler = new AirDropRouteHandler(ctx, peer, options.Value, logger); 66 | return executor(handler); 67 | 68 | Task NotFound() 69 | { 70 | // we couldn't map the host header to the underlying 71 | // channel, so there's nothing for us to handle: 404! 72 | logger.LogInformation( 73 | "Unable to find a connected channel from host header '{Host}'", 74 | ctx.Request.Host.Host 75 | ); 76 | ctx.Response.StatusCode = StatusCodes.Status404NotFound; 77 | return Task.CompletedTask; 78 | } 79 | } 80 | 81 | public async Task DiscoverAsync() 82 | { 83 | // TODO: handle contacts 84 | // we're currently operating in "Everyone" receive 85 | // mode which means discover will always return something 86 | // if there are any channels associated with the proxy 87 | var discoverRequest = await Request.ReadFromPropertyListAsync(); 88 | if (!discoverRequest.TryGetSenderRecordData(out _)) 89 | { 90 | Response.StatusCode = StatusCodes.Status400BadRequest; 91 | return; 92 | } 93 | 94 | await Response.WriteAsPropertyListAsync( 95 | new DiscoverResponse(_peer.Name, _peer.Name, MediaCapabilities.Default) 96 | ); 97 | } 98 | 99 | public async Task AskAsync() 100 | { 101 | var askRequest = await Request.ReadFromPropertyListAsync(); 102 | var canAcceptFiles = await _peer.CanAcceptFilesAsync(askRequest); 103 | if (!canAcceptFiles) 104 | { 105 | // 406 Not Acceptable if the underlying channel 106 | // did not accept the files 107 | Response.StatusCode = StatusCodes.Status406NotAcceptable; 108 | return; 109 | } 110 | 111 | // underlying channel accepted the files, tell the caller of the API 112 | await Response.WriteAsPropertyListAsync( 113 | new AskResponse(_peer.Name, _peer.Name) 114 | ); 115 | } 116 | 117 | public async Task UploadAsync() 118 | { 119 | if (Request.ContentType != "application/x-cpio") 120 | { 121 | // AirDrop also supports a format called "DvZip" 122 | // which appears to be completely undocumented 123 | // we explicitly _do not_ enable the flag that sends 124 | // this data format - so we're expecting a GZIP encoded 125 | // CPIO file - if we don't have that then return a 422 126 | Response.StatusCode = StatusCodes.Status422UnprocessableEntity; 127 | return; 128 | } 129 | 130 | // extract the CPIO file directly to disk 131 | var extractionPath = Path.Join(_options.UploadPath, Utils.GetRandomString()); 132 | if (!Directory.Exists(extractionPath)) 133 | { 134 | Directory.CreateDirectory(extractionPath); 135 | } 136 | 137 | try 138 | { 139 | // NOTE: Apple doesn't pass the Content-Encoding header 140 | // here but they do encode the request using gzip - so decompress 141 | // using that prior to extracting the cpio archive to disk 142 | await using (var requestStream = new GZipStream(Request.Body, CompressionMode.Decompress, true)) 143 | await using (var cpioArchiveReader = CpioArchiveReader.Create(requestStream)) 144 | { 145 | var extractedFiles = await cpioArchiveReader.ExtractAsync(extractionPath, Request.HttpContext.RequestAborted); 146 | // notify our peer of each file that was extracted 147 | // this gives the peer the opportunity to download the file 148 | // before the extraction directory is removed 149 | foreach (var extractedFile in extractedFiles) 150 | { 151 | await _peer.OnFileUploadedAsync(extractedFile); 152 | } 153 | } 154 | } 155 | finally 156 | { 157 | try 158 | { 159 | Directory.Delete(extractionPath, true); 160 | } 161 | catch (Exception ex) 162 | { 163 | // best effort to clean-up - if it fails 164 | // there's little we can do here, so leave the orphaned file 165 | _logger.LogWarning(ex, "Unable to delete extraction directory '{ExtractionPath}'", extractionPath); 166 | } 167 | } 168 | } 169 | } 170 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Mono auto generated files 17 | mono_crash.* 18 | 19 | # Build results 20 | [Dd]ebug/ 21 | [Dd]ebugPublic/ 22 | [Rr]elease/ 23 | [Rr]eleases/ 24 | x64/ 25 | x86/ 26 | [Ww][Ii][Nn]32/ 27 | [Aa][Rr][Mm]/ 28 | [Aa][Rr][Mm]64/ 29 | bld/ 30 | [Bb]in/ 31 | [Oo]bj/ 32 | [Ll]og/ 33 | [Ll]ogs/ 34 | 35 | # Visual Studio 2015/2017 cache/options directory 36 | .vs/ 37 | # Uncomment if you have tasks that create the project's static files in wwwroot 38 | #wwwroot/ 39 | 40 | # Visual Studio 2017 auto generated files 41 | Generated\ Files/ 42 | 43 | # MSTest test Results 44 | [Tt]est[Rr]esult*/ 45 | [Bb]uild[Ll]og.* 46 | 47 | # NUnit 48 | *.VisualState.xml 49 | TestResult.xml 50 | nunit-*.xml 51 | 52 | # Build Results of an ATL Project 53 | [Dd]ebugPS/ 54 | [Rr]eleasePS/ 55 | dlldata.c 56 | 57 | # Benchmark Results 58 | BenchmarkDotNet.Artifacts/ 59 | 60 | # .NET Core 61 | project.lock.json 62 | project.fragment.lock.json 63 | artifacts/ 64 | 65 | # ASP.NET Scaffolding 66 | ScaffoldingReadMe.txt 67 | 68 | # StyleCop 69 | StyleCopReport.xml 70 | 71 | # Files built by Visual Studio 72 | *_i.c 73 | *_p.c 74 | *_h.h 75 | *.ilk 76 | *.meta 77 | *.obj 78 | *.iobj 79 | *.pch 80 | *.pdb 81 | *.ipdb 82 | *.pgc 83 | *.pgd 84 | *.rsp 85 | *.sbr 86 | *.tlb 87 | *.tli 88 | *.tlh 89 | *.tmp 90 | *.tmp_proj 91 | *_wpftmp.csproj 92 | *.log 93 | *.vspscc 94 | *.vssscc 95 | .builds 96 | *.pidb 97 | *.svclog 98 | *.scc 99 | 100 | # Chutzpah Test files 101 | _Chutzpah* 102 | 103 | # Visual C++ cache files 104 | ipch/ 105 | *.aps 106 | *.ncb 107 | *.opendb 108 | *.opensdf 109 | *.sdf 110 | *.cachefile 111 | *.VC.db 112 | *.VC.VC.opendb 113 | 114 | # Visual Studio profiler 115 | *.psess 116 | *.vsp 117 | *.vspx 118 | *.sap 119 | 120 | # Visual Studio Trace Files 121 | *.e2e 122 | 123 | # TFS 2012 Local Workspace 124 | $tf/ 125 | 126 | # Guidance Automation Toolkit 127 | *.gpState 128 | 129 | # ReSharper is a .NET coding add-in 130 | _ReSharper*/ 131 | *.[Rr]e[Ss]harper 132 | *.DotSettings.user 133 | 134 | # TeamCity is a build add-in 135 | _TeamCity* 136 | 137 | # DotCover is a Code Coverage Tool 138 | *.dotCover 139 | 140 | # AxoCover is a Code Coverage Tool 141 | .axoCover/* 142 | !.axoCover/settings.json 143 | 144 | # Coverlet is a free, cross platform Code Coverage Tool 145 | coverage*.json 146 | coverage*.xml 147 | coverage*.info 148 | 149 | # Visual Studio code coverage results 150 | *.coverage 151 | *.coveragexml 152 | 153 | # NCrunch 154 | _NCrunch_* 155 | .*crunch*.local.xml 156 | nCrunchTemp_* 157 | 158 | # MightyMoose 159 | *.mm.* 160 | AutoTest.Net/ 161 | 162 | # Web workbench (sass) 163 | .sass-cache/ 164 | 165 | # Installshield output folder 166 | [Ee]xpress/ 167 | 168 | # DocProject is a documentation generator add-in 169 | DocProject/buildhelp/ 170 | DocProject/Help/*.HxT 171 | DocProject/Help/*.HxC 172 | DocProject/Help/*.hhc 173 | DocProject/Help/*.hhk 174 | DocProject/Help/*.hhp 175 | DocProject/Help/Html2 176 | DocProject/Help/html 177 | 178 | # Click-Once directory 179 | publish/ 180 | 181 | # Publish Web Output 182 | *.[Pp]ublish.xml 183 | *.azurePubxml 184 | # Note: Comment the next line if you want to checkin your web deploy settings, 185 | # but database connection strings (with potential passwords) will be unencrypted 186 | *.pubxml 187 | *.publishproj 188 | 189 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 190 | # checkin your Azure Web App publish settings, but sensitive information contained 191 | # in these scripts will be unencrypted 192 | PublishScripts/ 193 | 194 | # NuGet Packages 195 | *.nupkg 196 | # NuGet Symbol Packages 197 | *.snupkg 198 | # The packages folder can be ignored because of Package Restore 199 | **/[Pp]ackages/* 200 | # except build/, which is used as an MSBuild target. 201 | !**/[Pp]ackages/build/ 202 | # Uncomment if necessary however generally it will be regenerated when needed 203 | #!**/[Pp]ackages/repositories.config 204 | # NuGet v3's project.json files produces more ignorable files 205 | *.nuget.props 206 | *.nuget.targets 207 | 208 | # Microsoft Azure Build Output 209 | csx/ 210 | *.build.csdef 211 | 212 | # Microsoft Azure Emulator 213 | ecf/ 214 | rcf/ 215 | 216 | # Windows Store app package directories and files 217 | AppPackages/ 218 | BundleArtifacts/ 219 | Package.StoreAssociation.xml 220 | _pkginfo.txt 221 | *.appx 222 | *.appxbundle 223 | *.appxupload 224 | 225 | # Visual Studio cache files 226 | # files ending in .cache can be ignored 227 | *.[Cc]ache 228 | # but keep track of directories ending in .cache 229 | !?*.[Cc]ache/ 230 | 231 | # Others 232 | ClientBin/ 233 | ~$* 234 | *~ 235 | *.dbmdl 236 | *.dbproj.schemaview 237 | *.jfm 238 | *.pfx 239 | *.publishsettings 240 | orleans.codegen.cs 241 | 242 | # Including strong name files can present a security risk 243 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 244 | #*.snk 245 | 246 | # Since there are multiple workflows, uncomment next line to ignore bower_components 247 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 248 | #bower_components/ 249 | 250 | # RIA/Silverlight projects 251 | Generated_Code/ 252 | 253 | # Backup & report files from converting an old project file 254 | # to a newer Visual Studio version. Backup files are not needed, 255 | # because we have git ;-) 256 | _UpgradeReport_Files/ 257 | Backup*/ 258 | UpgradeLog*.XML 259 | UpgradeLog*.htm 260 | ServiceFabricBackup/ 261 | *.rptproj.bak 262 | 263 | # SQL Server files 264 | *.mdf 265 | *.ldf 266 | *.ndf 267 | 268 | # Business Intelligence projects 269 | *.rdl.data 270 | *.bim.layout 271 | *.bim_*.settings 272 | *.rptproj.rsuser 273 | *- [Bb]ackup.rdl 274 | *- [Bb]ackup ([0-9]).rdl 275 | *- [Bb]ackup ([0-9][0-9]).rdl 276 | 277 | # Microsoft Fakes 278 | FakesAssemblies/ 279 | 280 | # GhostDoc plugin setting file 281 | *.GhostDoc.xml 282 | 283 | # Node.js Tools for Visual Studio 284 | .ntvs_analysis.dat 285 | node_modules/ 286 | 287 | # Visual Studio 6 build log 288 | *.plg 289 | 290 | # Visual Studio 6 workspace options file 291 | *.opt 292 | 293 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 294 | *.vbw 295 | 296 | # Visual Studio LightSwitch build output 297 | **/*.HTMLClient/GeneratedArtifacts 298 | **/*.DesktopClient/GeneratedArtifacts 299 | **/*.DesktopClient/ModelManifest.xml 300 | **/*.Server/GeneratedArtifacts 301 | **/*.Server/ModelManifest.xml 302 | _Pvt_Extensions 303 | 304 | # Paket dependency manager 305 | .paket/paket.exe 306 | paket-files/ 307 | 308 | # FAKE - F# Make 309 | .fake/ 310 | 311 | # CodeRush personal settings 312 | .cr/personal 313 | 314 | # Python Tools for Visual Studio (PTVS) 315 | __pycache__/ 316 | *.pyc 317 | 318 | # Cake - Uncomment if you are using it 319 | # tools/** 320 | # !tools/packages.config 321 | 322 | # Tabs Studio 323 | *.tss 324 | 325 | # Telerik's JustMock configuration file 326 | *.jmconfig 327 | 328 | # BizTalk build output 329 | *.btp.cs 330 | *.btm.cs 331 | *.odx.cs 332 | *.xsd.cs 333 | 334 | # OpenCover UI analysis results 335 | OpenCover/ 336 | 337 | # Azure Stream Analytics local run output 338 | ASALocalRun/ 339 | 340 | # MSBuild Binary and Structured Log 341 | *.binlog 342 | 343 | # NVidia Nsight GPU debugger configuration file 344 | *.nvuser 345 | 346 | # MFractors (Xamarin productivity tool) working folder 347 | .mfractor/ 348 | 349 | # Local History for Visual Studio 350 | .localhistory/ 351 | 352 | # BeatPulse healthcheck temp database 353 | healthchecksdb 354 | 355 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 356 | MigrationBackup/ 357 | 358 | # Ionide (cross platform F# VS Code tools) working folder 359 | .ionide/ 360 | 361 | # Fody - auto-generated XML schema 362 | FodyWeavers.xsd 363 | -------------------------------------------------------------------------------- /src/AirDropAnywhere.Core/AirDropService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Concurrent; 3 | using System.Collections.Immutable; 4 | using System.Diagnostics.CodeAnalysis; 5 | using System.Linq; 6 | using System.Net; 7 | using System.Net.NetworkInformation; 8 | using System.Runtime.InteropServices; 9 | using System.Threading; 10 | using System.Threading.Tasks; 11 | using AirDropAnywhere.Core.MulticastDns; 12 | using Microsoft.Extensions.Hosting; 13 | using Microsoft.Extensions.Logging; 14 | using Microsoft.Extensions.Options; 15 | 16 | namespace AirDropAnywhere.Core 17 | { 18 | /// 19 | /// Hosted service that advertises AirDrop services via mDNS. 20 | /// 21 | public class AirDropService : IHostedService 22 | { 23 | private readonly IOptionsMonitor _optionsMonitor; 24 | private readonly ILogger _logger; 25 | private readonly ConcurrentDictionary _peersById = new(); 26 | 27 | private MulticastDnsServer? _mDnsServer; 28 | private CancellationTokenSource? _cancellationTokenSource; 29 | 30 | public AirDropService( 31 | IOptionsMonitor optionsMonitor, 32 | ILogger logger 33 | ) 34 | { 35 | Utils.AssertPlatform(); 36 | Utils.AssertNetworkInterfaces(); 37 | 38 | _optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor)); 39 | _logger = logger ?? throw new ArgumentNullException(nameof(logger)); 40 | } 41 | 42 | async Task IHostedService.StartAsync(CancellationToken cancellationToken) 43 | { 44 | // on macOS, make sure AWDL is spun up by the OS 45 | if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) 46 | { 47 | _logger.LogInformation("Starting AWDL..."); 48 | Interop.StartAWDLBrowsing(); 49 | } 50 | 51 | // we only support binding to the AWDL interface for mDNS 52 | // and existence of this interface is asserted when 53 | // this class is instantiated - GetNetworkInterfaces will 54 | // only return interfaces that support an implementation of AWDL. 55 | var networkInterfaces = GetNetworkInterfaces(i => i.IsAwdlInterface()); 56 | 57 | _cancellationTokenSource = new CancellationTokenSource(); 58 | _mDnsServer = new MulticastDnsServer(networkInterfaces, _cancellationTokenSource.Token); 59 | 60 | _logger.LogInformation("Starting mDNS listener..."); 61 | await _mDnsServer.StartAsync(); 62 | } 63 | 64 | async Task IHostedService.StopAsync(CancellationToken cancellationToken) 65 | { 66 | if (_cancellationTokenSource != null) 67 | { 68 | _cancellationTokenSource.Cancel(); 69 | _cancellationTokenSource = null; 70 | } 71 | 72 | if (_mDnsServer != null) 73 | { 74 | _logger.LogInformation("Stopping mDNS listener..."); 75 | await _mDnsServer.StopAsync(); 76 | _mDnsServer = null; 77 | } 78 | 79 | // on macOS, make sure AWDL is stopped by the OS 80 | if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) 81 | { 82 | _logger.LogInformation("Stopping AWDL..."); 83 | Interop.StopAWDLBrowsing(); 84 | } 85 | } 86 | 87 | /// 88 | /// Registers an so that it becomes discoverable to 89 | /// AirDrop-compatible devices. 90 | /// 91 | /// 92 | /// An instance of . 93 | /// 94 | public ValueTask RegisterPeerAsync(AirDropPeer peer) 95 | { 96 | _logger.LogInformation("Registering AirDrop peer '{Id}'...", peer.Id); 97 | 98 | var service = new MulticastDnsService.Builder() 99 | .SetNames("_airdrop._tcp", peer.Id, peer.Id) 100 | .AddEndpoints( 101 | GetNetworkInterfaces() 102 | .Select(i => i.GetIPProperties()) 103 | .SelectMany(p => p.UnicastAddresses) 104 | .Where(p => !IPAddress.IsLoopback(p.Address)) 105 | .Select(ip => new IPEndPoint(ip.Address, _optionsMonitor.CurrentValue.ListenPort)) 106 | ) 107 | .AddProperty("flags", ((uint) AirDropReceiverFlags.Default).ToString()) 108 | .Build(); 109 | 110 | // keep a record of the peer and its service 111 | _peersById.AddOrUpdate( 112 | peer.Id, 113 | (_, value) => value, 114 | (_, _, newValue) => newValue, 115 | new PeerMetadata(peer, service) 116 | ); 117 | 118 | // and broadcast its existence to the world 119 | return _mDnsServer!.RegisterAsync(service); 120 | } 121 | 122 | /// 123 | /// Unregisters an so that it is no longer discoverable by 124 | /// AirDrop-compatible devices. If the peer is not registered then this operation is no-op. 125 | /// 126 | /// 127 | /// A previously registered instance of . 128 | /// 129 | public ValueTask UnregisterPeerAsync(AirDropPeer peer) 130 | { 131 | _logger.LogInformation("Unregistering AirDrop peer '{Id}'...", peer.Id); 132 | if (!_peersById.TryRemove(peer.Id, out var peerMetadata)) 133 | { 134 | return default; 135 | } 136 | 137 | return _mDnsServer!.UnregisterAsync(peerMetadata.Service); 138 | } 139 | 140 | /// 141 | /// Attempts to get an by its unique identifier. 142 | /// 143 | /// Unique identifier of a peer. 144 | /// 145 | /// If found, the instance of identified by , 146 | /// null otherwise. 147 | /// 148 | /// 149 | /// true if the peer was found, false otherwise. 150 | /// 151 | public bool TryGetPeer(string id, [MaybeNullWhen(false)] out AirDropPeer peer) 152 | { 153 | if (!_peersById.TryGetValue(id, out var peerMetadata)) 154 | { 155 | peer = default; 156 | return false; 157 | } 158 | 159 | peer = peerMetadata.Peer; 160 | return true; 161 | } 162 | 163 | private static ImmutableArray GetNetworkInterfaces(Func? filter = null) 164 | { 165 | var networkInterfaces = NetworkInterface.GetAllNetworkInterfaces() 166 | .Where(i => i.SupportsMulticast) 167 | .Where(i => i.OperationalStatus == OperationalStatus.Up) 168 | .Where(i => i.NetworkInterfaceType != NetworkInterfaceType.Ppp) 169 | .Where(i => i.IsAwdlInterface()); 170 | 171 | if (filter != null) 172 | { 173 | networkInterfaces = networkInterfaces.Where(filter); 174 | } 175 | return networkInterfaces.ToImmutableArray(); 176 | } 177 | 178 | private readonly struct PeerMetadata 179 | { 180 | public AirDropPeer Peer { get; } 181 | public MulticastDnsService Service { get; } 182 | 183 | public PeerMetadata(AirDropPeer peer, MulticastDnsService service) 184 | { 185 | Peer = peer; 186 | Service = service; 187 | } 188 | } 189 | } 190 | } -------------------------------------------------------------------------------- /src/AirDropAnywhere.Cli/Commands/ServerCommand.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Text.Json; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | using AirDropAnywhere.Cli.Hubs; 7 | using AirDropAnywhere.Cli.Logging; 8 | using AirDropAnywhere.Core.Certificates; 9 | using Microsoft.AspNetCore; 10 | using Microsoft.AspNetCore.Builder; 11 | using Microsoft.AspNetCore.Hosting; 12 | using Microsoft.AspNetCore.Hosting.Server.Features; 13 | using Microsoft.Extensions.DependencyInjection; 14 | using Microsoft.Extensions.FileProviders; 15 | using Microsoft.Extensions.Logging; 16 | using Spectre.Console; 17 | using Spectre.Console.Cli; 18 | 19 | namespace AirDropAnywhere.Cli.Commands 20 | { 21 | internal class ServerCommand : CommandBase 22 | { 23 | public ServerCommand(IAnsiConsole console, ILogger logger) : base(console, logger) 24 | { 25 | } 26 | 27 | public override async Task ExecuteAsync(CommandContext context, Settings settings) 28 | { 29 | var webHost = default(IWebHost); 30 | using var cancellationTokenSource = new CancellationTokenSource(); 31 | 32 | Logger.LogInformation( 33 | "Generating self-signed certificate for HTTPS" 34 | ); 35 | 36 | using var cert = CertificateManager.Create(); 37 | 38 | await Console.Status() 39 | .Spinner(Spinner.Known.Earth) 40 | .StartAsync( 41 | "Starting AirDrop services...", 42 | async _ => 43 | { 44 | 45 | webHost = WebHost.CreateDefaultBuilder() 46 | .ConfigureLogging( 47 | (hostContext, builder) => 48 | { 49 | builder.ClearProviders(); 50 | builder.AddConfiguration(hostContext.Configuration.GetSection("Logging")); 51 | builder.AddProvider(new SpectreInlineLoggerProvider(Console)); 52 | } 53 | ) 54 | .ConfigureKestrel( 55 | options => options.ConfigureAirDropDefaults(cert) 56 | ) 57 | .ConfigureServices( 58 | (hostContext, services) => 59 | { 60 | var uploadPath = Path.Join( 61 | hostContext.HostingEnvironment.ContentRootPath, 62 | "uploads" 63 | ); 64 | 65 | Directory.CreateDirectory(uploadPath); 66 | 67 | services.Configure(options => 68 | { 69 | options.FileProvider = new CompositeFileProvider( 70 | new IFileProvider[] 71 | { 72 | // provides access to the files embedded in the assembly 73 | new ManifestEmbeddedFileProvider( 74 | typeof(ServerCommand).Assembly, "wwwroot" 75 | ), 76 | // provides access to uploaded files 77 | new PhysicalFileProvider(uploadPath) 78 | } 79 | ); 80 | 81 | // we don't know what files could be uploaded using AirDrop 82 | // so enable everything by default 83 | options.ServeUnknownFileTypes = true; 84 | } 85 | ); 86 | services.AddAirDrop( 87 | options => 88 | { 89 | options.ListenPort = settings.Port; 90 | options.UploadPath = uploadPath; 91 | } 92 | ); 93 | services.AddRouting(); 94 | services 95 | .AddSignalR( 96 | options => 97 | { 98 | options.EnableDetailedErrors = true; 99 | } 100 | ) 101 | .AddJsonProtocol( 102 | options => 103 | { 104 | options.PayloadSerializerOptions = new JsonSerializerOptions 105 | { 106 | Converters = 107 | { 108 | PolymorphicJsonConverter.Create(typeof(AirDropHubMessage)) 109 | } 110 | }; 111 | } 112 | ); 113 | } 114 | ) 115 | .Configure( 116 | app => 117 | { 118 | app 119 | .UseRouting() 120 | .UseStaticFiles() 121 | .UseEndpoints( 122 | endpoints => 123 | { 124 | endpoints.MapAirDrop(); 125 | endpoints.MapHub("/airdrop"); 126 | endpoints.MapFallbackToFile("index.html"); 127 | } 128 | ); 129 | 130 | } 131 | ) 132 | .SuppressStatusMessages(true) 133 | .Build(); 134 | 135 | await webHost.StartAsync(cancellationTokenSource.Token); 136 | } 137 | ); 138 | 139 | var feature = webHost!.ServerFeatures.Get(); 140 | if (feature != null) 141 | { 142 | foreach (var address in feature.Addresses) 143 | { 144 | Logger.LogInformation("Listening on {Url}", address); 145 | } 146 | } 147 | 148 | Logger.LogInformation("Waiting for AirDrop clients..."); 149 | 150 | // ReSharper disable AccessToDisposedClosure 151 | void Shutdown() 152 | { 153 | if (!cancellationTokenSource.IsCancellationRequested) 154 | { 155 | Logger.LogInformation("Shutting down AirDrop services..."); 156 | cancellationTokenSource.Cancel(); 157 | } 158 | } 159 | 160 | AppDomain.CurrentDomain.ProcessExit += (_, _) => Shutdown(); 161 | System.Console.CancelKeyPress += (_, _) => Shutdown(); 162 | await webHost.WaitForShutdownAsync(cancellationTokenSource.Token); 163 | return 0; 164 | } 165 | 166 | public class Settings : CommandSettings 167 | { 168 | [CommandOption("--port")] 169 | public ushort Port { get; init; } = default!; 170 | 171 | public override ValidationResult Validate() 172 | { 173 | if (Port == 0) 174 | { 175 | return ValidationResult.Error("Invalid port specified."); 176 | } 177 | 178 | return base.Validate(); 179 | } 180 | } 181 | } 182 | } -------------------------------------------------------------------------------- /src/AirDropAnywhere.Core/MulticastDns/UdpAwaitableSocketAsyncEventArgs.cs: -------------------------------------------------------------------------------- 1 | // MIT license 2 | // https://github.com/enclave-networks/research.udp-perf/tree/main/Enclave.UdpPerf 3 | 4 | using System; 5 | using System.Net.Sockets; 6 | using System.Threading; 7 | using System.Threading.Tasks; 8 | using System.Threading.Tasks.Sources; 9 | 10 | // ReSharper disable once CheckNamespace 11 | namespace Enclave.UdpPerf 12 | { 13 | /// 14 | /// Provides a derived implementation of that allows fast UDP send/receive activity. 15 | /// 16 | /// 17 | /// Note that this implementation isn't perfect if you are using async context. There is more work to be done if you need 18 | /// to make sure async context is fully preserved, but at the expense of performance. 19 | /// Heavily inspired by the AwaitableSocketAsyncEventArgs class inside the dotnet runtime: 20 | /// https://github.com/dotnet/runtime/blob/9bb4bfe7b84ce0e05e55059fd3ab99448176d5ac/src/libraries/System.Net.Sockets/src/System/Net/Sockets/Socket.Tasks.cs#L746 21 | /// 22 | internal sealed class UdpAwaitableSocketAsyncEventArgs : SocketAsyncEventArgs, IValueTaskSource 23 | { 24 | private static readonly Action _completedSentinel = state => throw new InvalidOperationException("Task misuse"); 25 | 26 | // This _token is a basic attempt to protect against misuse of the value tasks we return from this source. 27 | // Not perfect, intended to be 'best effort'. 28 | private short _token; 29 | private Action? _continuation; 30 | 31 | // Note the use of 'unsafeSuppressExecutionContextFlow'; this is an optimisation new to .NET5. We are not concerned with execution context preservation 32 | // in our example, so we can disable it for a slight perf boost. 33 | public UdpAwaitableSocketAsyncEventArgs() 34 | : base(unsafeSuppressExecutionContextFlow: true) 35 | { 36 | } 37 | 38 | public ValueTask ReceiveFromAsync(Socket socket) 39 | { 40 | // Call our socket method to do the receive. 41 | if (socket.ReceiveMessageFromAsync(this)) 42 | { 43 | // ReceiveFromAsync will return true if we are going to complete later. 44 | // So we return a ValueTask, passing 'this' (our IValueTaskSource) 45 | // to the constructor. We will then tell the ValueTask when we are 'done'. 46 | return new ValueTask(this, _token); 47 | } 48 | 49 | // If our socket method returns false, it means the call already complete synchronously; for example 50 | // if there is data in the socket buffer all ready to go, we'll usually complete this way. 51 | return CompleteSynchronously(); 52 | } 53 | 54 | public ValueTask DoSendToAsync(Socket socket) 55 | { 56 | // Send looks very similar to send, just calling a different method on the socket. 57 | if (socket.SendToAsync(this)) 58 | { 59 | return new ValueTask(this, _token); 60 | } 61 | 62 | return CompleteSynchronously(); 63 | } 64 | 65 | private ValueTask CompleteSynchronously() 66 | { 67 | // Completing synchronously, so we don't need to preserve the 68 | // async bits. 69 | Reset(); 70 | 71 | var error = SocketError; 72 | if (error == SocketError.Success) 73 | { 74 | // Return a ValueTask directly, in a no-alloc operation. 75 | return new ValueTask(BytesTransferred); 76 | } 77 | 78 | // Fail synchronously. 79 | return ValueTask.FromException(new SocketException((int)error)); 80 | } 81 | 82 | // This method is called by the base class when our async socket operation completes. 83 | // The goal here is to wake up our 84 | protected override void OnCompleted(SocketAsyncEventArgs e) 85 | { 86 | Action? c = _continuation; 87 | 88 | // This Interlocked.Exchange is intended to ensure that only one path can end up invoking the 89 | // continuation that completes the ValueTask. We swap for our _completedSentinel, and only proceed if 90 | // there was a continuation action present in that field. 91 | if (c != null || (c = Interlocked.CompareExchange(ref _continuation, _completedSentinel, null)) != null) 92 | { 93 | object? continuationState = UserToken; 94 | UserToken = null; 95 | 96 | // Mark us as done. 97 | _continuation = _completedSentinel; 98 | 99 | // Invoke the continuation. Because this completion is, by its nature, happening asynchronously, 100 | // we don't need to force an async invoke. 101 | InvokeContinuation(c, continuationState, forceAsync: false); 102 | } 103 | } 104 | 105 | // This method is invoked if someone calls ValueTask.IsCompleted (for example) and when the operation completes, and needs to indicate the 106 | // state of the current operation. 107 | public ValueTaskSourceStatus GetStatus(short token) 108 | { 109 | if (token != _token) 110 | { 111 | ThrowMisuseException(); 112 | } 113 | 114 | // If _continuation isn't _completedSentinel, we're still going. 115 | return !ReferenceEquals(_continuation, _completedSentinel) ? ValueTaskSourceStatus.Pending : 116 | SocketError == SocketError.Success ? ValueTaskSourceStatus.Succeeded : 117 | ValueTaskSourceStatus.Faulted; 118 | } 119 | 120 | // This method is only called once per ValueTask, once GetStatus returns something other than 121 | // ValueTaskSourceStatus.Pending. 122 | public int GetResult(short token) 123 | { 124 | // Detect multiple awaits on a single ValueTask. 125 | if (token != _token) 126 | { 127 | ThrowMisuseException(); 128 | } 129 | 130 | // We're done, reset. 131 | Reset(); 132 | 133 | // Now we just return the result (or throw if there was an error). 134 | var error = SocketError; 135 | if (error == SocketError.Success) 136 | { 137 | return BytesTransferred; 138 | } 139 | 140 | throw new SocketException((int)error); 141 | } 142 | 143 | // This is called when someone awaits on the ValueTask, and tells us what method to call to complete. 144 | public void OnCompleted(Action continuation, object? state, short token, ValueTaskSourceOnCompletedFlags flags) 145 | { 146 | if (token != _token) 147 | { 148 | ThrowMisuseException(); 149 | } 150 | 151 | UserToken = state; 152 | 153 | // Do the exchange so we know we're the only ones that could invoke the continuation. 154 | Action? prevContinuation = Interlocked.CompareExchange(ref _continuation, continuation, null); 155 | 156 | // Check whether we've already finished. 157 | if (ReferenceEquals(prevContinuation, _completedSentinel)) 158 | { 159 | // This means the operation has already completed; most likely because we completed before 160 | // we could attach the continuation. 161 | // Don't need to store the user token. 162 | UserToken = null; 163 | 164 | // We need to set forceAsync here and dispatch on the ThreadPool, otherwise 165 | // we can hit a stackoverflow! 166 | InvokeContinuation(continuation, state, forceAsync: true); 167 | } 168 | else if (prevContinuation != null) 169 | { 170 | throw new InvalidOperationException("Continuation being attached more than once."); 171 | } 172 | } 173 | 174 | private void InvokeContinuation(Action continuation, object? state, bool forceAsync) 175 | { 176 | if (forceAsync) 177 | { 178 | // Dispatch the operation on the thread pool. 179 | ThreadPool.UnsafeQueueUserWorkItem(continuation, state, preferLocal: true); 180 | } 181 | else 182 | { 183 | // Just complete the continuation inline (on the IO thread that completed the socket operation). 184 | continuation(state); 185 | } 186 | } 187 | 188 | private void Reset() 189 | { 190 | // Increment our token for the next operation. 191 | _token++; 192 | _continuation = null; 193 | } 194 | 195 | private static void ThrowMisuseException() 196 | { 197 | throw new InvalidOperationException("ValueTask mis-use; multiple await?"); 198 | } 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /src/AirDropAnywhere.Cli/Commands/ClientCommand.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Net.Http; 4 | using System.Net.Security; 5 | using System.Text; 6 | using System.Text.Json; 7 | using System.Threading; 8 | using System.Threading.Channels; 9 | using System.Threading.Tasks; 10 | using AirDropAnywhere.Cli.Hubs; 11 | using Microsoft.AspNetCore.SignalR.Client; 12 | using Microsoft.Extensions.DependencyInjection; 13 | using Microsoft.Extensions.Logging; 14 | using Spectre.Console; 15 | using Spectre.Console.Cli; 16 | 17 | namespace AirDropAnywhere.Cli.Commands 18 | { 19 | internal class ClientCommand : CommandBase 20 | { 21 | private readonly HttpClient _httpClient; 22 | 23 | public ClientCommand(IAnsiConsole console, ILogger logger, IHttpClientFactory httpClientFactory) : base(console, logger) 24 | { 25 | if (httpClientFactory == null) 26 | { 27 | throw new ArgumentNullException(nameof(httpClientFactory)); 28 | } 29 | 30 | _httpClient = httpClientFactory.CreateClient("airdrop"); 31 | } 32 | 33 | public override async Task ExecuteAsync(CommandContext context, Settings settings) 34 | { 35 | var uri = new UriBuilder( 36 | Uri.UriSchemeHttps, settings.Server, settings.Port, "/airdrop" 37 | ).Uri; 38 | 39 | await using var hubConnection = CreateHubConnection(uri); 40 | using var cancellationTokenSource = CreateCancellationTokenSource(); 41 | 42 | await Console.Status() 43 | .StartAsync( 44 | $"Connecting to [bold]{settings.Server}:{settings.Port}[/]", 45 | _ => hubConnection.StartAsync(cancellationTokenSource.Token) 46 | ); 47 | 48 | var clientChannel = Channel.CreateUnbounded(); 49 | 50 | // start the full duplex stream between the server and the client 51 | // we'll initially send our "connect" message to identify ourselves 52 | // and then wait for the server to push messages to us 53 | var serverMessages = hubConnection.StreamAsync( 54 | nameof(AirDropHub.StreamAsync), 55 | clientChannel.Reader.ReadAllAsync(cancellationTokenSource.Token) 56 | ); 57 | 58 | Logger.LogInformation("Registering client..."); 59 | await clientChannel.Writer.WriteAsync( 60 | await AirDropHubMessage.CreateAsync( 61 | m => 62 | { 63 | m.Name = Environment.MachineName; 64 | return default; 65 | } 66 | ), 67 | cancellationTokenSource.Token 68 | ); 69 | 70 | Logger.LogInformation("Waiting for peers..."); 71 | await foreach (var message in serverMessages.WithCancellation(cancellationTokenSource.Token)) 72 | { 73 | Logger.LogDebug("Received '{MessageType}' message...", message.GetType()); 74 | switch (message) 75 | { 76 | case CanAcceptFilesRequestMessage askRequest: 77 | 78 | await clientChannel.Writer.WriteAsync( 79 | await AirDropHubMessage.CreateAsync( 80 | async (CanAcceptFilesResponseMessage m, CanAcceptFilesRequestMessage r) => 81 | { 82 | m.Accepted = await OnCanAcceptFilesAsync(askRequest); 83 | m.ReplyTo = r.Id; 84 | }, 85 | askRequest 86 | ), 87 | cancellationTokenSource.Token 88 | ); 89 | break; 90 | 91 | case OnFileUploadedRequestMessage fileUploadedRequest: 92 | 93 | await clientChannel.Writer.WriteAsync( 94 | await AirDropHubMessage.CreateAsync( 95 | async (OnFileUploadedResponseMessage m, OnFileUploadedRequestMessage r) => 96 | { 97 | m.ReplyTo = r.Id; 98 | 99 | // download the file to our download path 100 | await DownloadFileAsync(r, settings.Path); 101 | }, 102 | fileUploadedRequest 103 | ), 104 | cancellationTokenSource.Token 105 | ); 106 | break; 107 | 108 | default: 109 | Logger.LogWarning("No handler for '{MessageType}' message", message.GetType()); 110 | break; 111 | } 112 | } 113 | 114 | // deliberately not passing cancellation token here 115 | // by the time we make it here the token is already cancelled 116 | // and we wouldn't be able to stop the connection gracefully! 117 | await hubConnection.StopAsync(); 118 | return 0; 119 | } 120 | 121 | private CancellationTokenSource CreateCancellationTokenSource() 122 | { 123 | var cancellationTokenSource = new CancellationTokenSource(); 124 | 125 | void Disconnect() 126 | { 127 | if (!cancellationTokenSource.IsCancellationRequested) 128 | { 129 | Logger.LogInformation("Disconnecting..."); 130 | cancellationTokenSource.Cancel(); 131 | } 132 | } 133 | 134 | System.Console.CancelKeyPress += (_, _) => Disconnect(); 135 | return cancellationTokenSource; 136 | } 137 | 138 | private HubConnection CreateHubConnection(Uri uri) 139 | { 140 | return new HubConnectionBuilder() 141 | .AddJsonProtocol( 142 | options => 143 | { 144 | options.PayloadSerializerOptions = new JsonSerializerOptions 145 | { 146 | Converters = 147 | { 148 | PolymorphicJsonConverter.Create(typeof(AirDropHubMessage)) 149 | } 150 | }; 151 | } 152 | ) 153 | .WithUrl(uri, options => 154 | { 155 | // ignore TLS certificate errors - we're deliberately 156 | // using self-signed certificates so no need to worry 157 | // about validating them here 158 | options.HttpMessageHandlerFactory = _ => 159 | { 160 | return new SocketsHttpHandler 161 | { 162 | SslOptions = new SslClientAuthenticationOptions 163 | { 164 | RemoteCertificateValidationCallback = delegate { return true; } 165 | } 166 | }; 167 | }; 168 | }) 169 | .Build(); 170 | } 171 | private ValueTask OnCanAcceptFilesAsync(CanAcceptFilesRequestMessage request) 172 | { 173 | var stringBuilder = new StringBuilder() 174 | .Append("Incoming files from [bold]") 175 | .Append(request.SenderComputerName) 176 | .AppendLine("[/]:"); 177 | 178 | foreach (var file in request.Files) 179 | { 180 | stringBuilder.Append(" ‣ ").AppendLine(file.Name); 181 | } 182 | 183 | Console.MarkupLine(stringBuilder.ToString()); 184 | 185 | return new( 186 | Console.Prompt( 187 | new ConfirmationPrompt("Accept?") 188 | ) 189 | ); 190 | } 191 | 192 | private async ValueTask DownloadFileAsync(OnFileUploadedRequestMessage request, string basePath) 193 | { 194 | Logger.LogInformation("Downloading '{File}'...", request.Name); 195 | var downloadPath = Path.Join(basePath, request.Name); 196 | using var response = await _httpClient.GetAsync(request.Url); 197 | if (!response.IsSuccessStatusCode) 198 | { 199 | Logger.LogError( 200 | "Unable to download file '{File}': {ResponseCode}", 201 | request.Name, 202 | response.StatusCode 203 | ); 204 | return; 205 | } 206 | 207 | await using var outputStream = File.Create(downloadPath); 208 | await response.Content.CopyToAsync(outputStream); 209 | Logger.LogInformation("Downloaded '{File}'...", request.Name); 210 | } 211 | 212 | public class Settings : CommandSettings 213 | { 214 | [CommandOption("--server")] 215 | public string Server { get; init; } = null!; 216 | 217 | [CommandOption("--port")] 218 | public ushort Port { get; init; } = default!; 219 | 220 | [CommandOption("--path")] 221 | public string Path { get; init; } = null!; 222 | 223 | public override ValidationResult Validate() 224 | { 225 | if (string.IsNullOrEmpty(Server)) 226 | { 227 | return ValidationResult.Error("Invalid server specified."); 228 | } 229 | 230 | if (Port == 0) 231 | { 232 | return ValidationResult.Error("Invalid port specified."); 233 | } 234 | 235 | if (string.IsNullOrEmpty(Path)) 236 | { 237 | return ValidationResult.Error("Invalid path specified."); 238 | } 239 | 240 | if (!Directory.Exists(Path)) 241 | { 242 | return ValidationResult.Error("Specified path does not exist."); 243 | } 244 | 245 | return base.Validate(); 246 | } 247 | } 248 | } 249 | } -------------------------------------------------------------------------------- /src/AirDropAnywhere.Core/Serialization/PropertyListConverter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections; 3 | using System.Collections.Generic; 4 | using System.Reflection; 5 | using System.Runtime.Serialization; 6 | using Claunia.PropertyList; 7 | 8 | namespace AirDropAnywhere.Core.Serialization 9 | { 10 | /// 11 | /// Helper class for converting between .NET types and the 12 | /// type hierarchy used by plist-cil. 13 | /// 14 | internal static class PropertyListConverter 15 | { 16 | private const BindingFlags PropertyFlags = BindingFlags.Instance | BindingFlags.Public; 17 | 18 | // ReSharper disable once InconsistentNaming 19 | public static NSObject? ToNSObject(object? obj) 20 | { 21 | if (obj == null) 22 | { 23 | return null; 24 | } 25 | 26 | var type = obj.GetType(); 27 | if (type.IsPrimitive || type == typeof(string) || type == typeof(byte[])) 28 | { 29 | // NSObject can deal with this itself 30 | return NSObject.Wrap(obj); 31 | } 32 | 33 | if (IsSet(type)) 34 | { 35 | var nsSet = new NSSet(); 36 | foreach (var value in (IEnumerable) obj) 37 | { 38 | nsSet.AddObject(ToNSObject(value)); 39 | } 40 | return nsSet; 41 | } 42 | 43 | if (IsDictionary(type)) 44 | { 45 | var nsDictionary = new NSDictionary(); 46 | foreach (DictionaryEntry kvp in (IDictionary) obj) 47 | { 48 | nsDictionary.Add((string)kvp.Key, ToNSObject(kvp.Value)); 49 | } 50 | return nsDictionary; 51 | } 52 | 53 | if (IsEnumerable(type)) 54 | { 55 | var nsArray = new NSArray(); 56 | foreach (var value in (IEnumerable) obj) 57 | { 58 | nsArray.Add(ToNSObject(value)); 59 | } 60 | return nsArray; 61 | } 62 | 63 | var dict = new NSDictionary(); 64 | foreach (var property in type.GetProperties(PropertyFlags)) 65 | { 66 | var name = property.Name; 67 | var dataMemberAttr = property.GetCustomAttribute(); 68 | if (dataMemberAttr?.Name != null) 69 | { 70 | name = dataMemberAttr.Name; 71 | } 72 | 73 | dict.Add(name, ToNSObject(property.GetValue(obj))); 74 | } 75 | return dict; 76 | } 77 | 78 | /// 79 | /// Converts an into the specified type . It uses reflection to inspect 80 | /// the properties on and materializes 81 | /// an instance of the type, populated with the contents of an . 82 | /// 83 | public static T ToObject(NSObject root) => (T)ToObject(root, typeof(T)); 84 | 85 | /// 86 | /// Helper method that converts an into 87 | /// the specified . It uses reflection to inspect 88 | /// the properties on and materializes 89 | /// an instance of the type, populated with the contents of an . 90 | /// 91 | public static object ToObject(NSObject root, Type type) 92 | { 93 | InvalidCastException InvalidType() => new( 94 | $"Unable to bind '{root.GetType()}' to collection type '{type}'" 95 | ); 96 | 97 | if (type == typeof(byte[]) && root is NSData nsData) 98 | { 99 | return nsData.Bytes; 100 | } 101 | 102 | if (root is NSSet nsSet) 103 | { 104 | var elementType = GetElementType(type); 105 | if (elementType == null) 106 | { 107 | throw InvalidType(); 108 | } 109 | 110 | var set = (IList) Activator.CreateInstance(typeof(HashSet<>).MakeGenericType(elementType))!; 111 | foreach (NSObject nsObject in nsSet) 112 | { 113 | set.Add(ToObject(nsObject, elementType)); 114 | } 115 | 116 | return set; 117 | } 118 | 119 | if (root is NSArray nsArray) 120 | { 121 | var elementType = GetElementType(type); 122 | if (elementType == null) 123 | { 124 | throw InvalidType(); 125 | } 126 | 127 | var list = (IList) Activator.CreateInstance(typeof(List<>).MakeGenericType(elementType))!; 128 | for (int i = 0; i < nsArray.Count; i++) 129 | { 130 | list.Add(ToObject(nsArray[i], elementType)); 131 | } 132 | 133 | return list; 134 | } 135 | 136 | if (root is NSNumber nsNumber) 137 | { 138 | if (type == typeof(bool)) 139 | { 140 | return nsNumber.ToBool(); 141 | } 142 | 143 | if (type == typeof(double)) 144 | { 145 | return nsNumber.ToDouble(); 146 | } 147 | 148 | if (type == typeof(float)) 149 | { 150 | return nsNumber.floatValue(); 151 | } 152 | 153 | if (type == typeof(int)) 154 | { 155 | return nsNumber.ToInt(); 156 | } 157 | 158 | if (type == typeof(long)) 159 | { 160 | return nsNumber.ToLong(); 161 | } 162 | 163 | throw InvalidType(); 164 | } 165 | 166 | if (root is NSString nsString) 167 | { 168 | if (type == typeof(string)) 169 | { 170 | return nsString.Content; 171 | } 172 | 173 | throw InvalidType(); 174 | } 175 | 176 | if (root is NSDate nsDate) 177 | { 178 | if (type == typeof(DateTime)) 179 | { 180 | return nsDate.Date; 181 | } 182 | 183 | throw InvalidType(); 184 | } 185 | 186 | if (root is NSDictionary nsDictionary) 187 | { 188 | if (type.IsPrimitive) 189 | { 190 | // can't convert a dictionary to a primitive type 191 | throw InvalidType(); 192 | } 193 | 194 | var elementType = GetDictionaryValueType(type); 195 | if (elementType != null) 196 | { 197 | var dict = (IDictionary) Activator.CreateInstance( 198 | typeof(Dictionary<,>).MakeGenericType(typeof(string), elementType) 199 | )!; 200 | foreach (var kvp in nsDictionary) 201 | { 202 | dict.Add(kvp.Key, ToObject(kvp.Value, elementType)); 203 | } 204 | 205 | return dict; 206 | } 207 | 208 | // construct an object that we can use 209 | // and populate its properties 210 | var instance = Activator.CreateInstance(type)!; 211 | foreach (var property in type.GetProperties(PropertyFlags)) 212 | { 213 | if (!property.CanWrite) 214 | { 215 | continue; 216 | } 217 | 218 | var name = property.Name; 219 | var dataMemberAttr = property.GetCustomAttribute(); 220 | if (dataMemberAttr?.Name != null) 221 | { 222 | name = dataMemberAttr.Name; 223 | } 224 | 225 | if (nsDictionary.TryGetValue(name, out var nsObject)) 226 | { 227 | property.SetValue(instance, ToObject(nsObject, property.PropertyType)); 228 | } 229 | } 230 | 231 | return instance; 232 | } 233 | 234 | throw InvalidType(); 235 | } 236 | 237 | private static Type? GetDictionaryValueType(Type type) 238 | { 239 | static bool IsDictionaryType(Type type) => type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IDictionary<,>); 240 | 241 | if (IsDictionaryType(type)) 242 | { 243 | return type.GetGenericArguments()[1]; 244 | } 245 | 246 | foreach (var interfaceType in type.GetInterfaces()) 247 | { 248 | if (IsDictionaryType(interfaceType)) 249 | { 250 | return interfaceType.GetGenericArguments()[1]; 251 | } 252 | } 253 | 254 | if (type.GetInterface(nameof(IDictionary)) != null) 255 | { 256 | return typeof(object); 257 | } 258 | 259 | return null; 260 | } 261 | 262 | private static bool IsDictionary(Type type) 263 | { 264 | if (HasInterface(type, typeof(IDictionary))) 265 | { 266 | return true; 267 | } 268 | 269 | return HasInterface(type, typeof(IDictionary<,>)); 270 | } 271 | 272 | private static bool IsSet(Type type) => HasInterface(type, typeof(ISet<>)); 273 | 274 | private static bool IsEnumerable(Type type) 275 | { 276 | if (type == typeof(string)) 277 | { 278 | // strings are IEnumerable but we don't want 279 | // to treat them that way! 280 | return false; 281 | } 282 | 283 | if (type.IsArray) 284 | { 285 | return true; 286 | } 287 | 288 | if (HasInterface(type, typeof(IEnumerable))) 289 | { 290 | return true; 291 | } 292 | 293 | return HasInterface(type, typeof(IEnumerable<>)); 294 | } 295 | 296 | private static Type? GetElementType(Type type) 297 | { 298 | if (type == typeof(string)) 299 | { 300 | return null; 301 | } 302 | 303 | if (type.IsArray) 304 | { 305 | return type.GetElementType(); 306 | } 307 | 308 | static bool IsEnumerableType(Type type) => type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IEnumerable<>); 309 | 310 | if (IsEnumerableType(type)) 311 | { 312 | return type.GetGenericArguments()[0]; 313 | } 314 | 315 | foreach (var interfaceType in type.GetInterfaces()) 316 | { 317 | if (IsEnumerableType(interfaceType)) 318 | { 319 | return interfaceType.GetGenericArguments()[0]; 320 | } 321 | } 322 | 323 | if (type.GetInterface(nameof(IEnumerable)) != null) 324 | { 325 | return typeof(object); 326 | } 327 | 328 | return null; 329 | } 330 | 331 | private static bool HasInterface(Type type, Type interfaceType) 332 | { 333 | if (type == null) 334 | { 335 | throw new ArgumentNullException(nameof(type)); 336 | } 337 | 338 | if (interfaceType == null) 339 | { 340 | throw new ArgumentNullException(nameof(interfaceType)); 341 | } 342 | 343 | if (!interfaceType.IsInterface) 344 | { 345 | throw new ArgumentException("Must be an interface type", nameof(interfaceType)); 346 | } 347 | 348 | bool ImplementsInterface(Type typeToCheck) 349 | { 350 | if (interfaceType.IsGenericTypeDefinition) 351 | { 352 | return typeToCheck.IsGenericType && typeToCheck.GetGenericTypeDefinition() == interfaceType; 353 | } 354 | 355 | return typeToCheck == interfaceType; 356 | } 357 | 358 | if (ImplementsInterface(type)) 359 | { 360 | return true; 361 | } 362 | 363 | foreach (var implementedType in type.GetInterfaces()) 364 | { 365 | if (ImplementsInterface(implementedType)) 366 | { 367 | return true; 368 | } 369 | } 370 | 371 | return false; 372 | } 373 | } 374 | } -------------------------------------------------------------------------------- /src/AirDropAnywhere.Cli/Hubs/AirDropHub.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Runtime.CompilerServices; 6 | using System.Threading; 7 | using System.Threading.Channels; 8 | using System.Threading.Tasks; 9 | using System.Threading.Tasks.Sources; 10 | using AirDropAnywhere.Core; 11 | using AirDropAnywhere.Core.Protocol; 12 | using Microsoft.AspNetCore.Http.Extensions; 13 | using Microsoft.AspNetCore.SignalR; 14 | using Microsoft.Extensions.Logging; 15 | using Microsoft.Extensions.ObjectPool; 16 | using Microsoft.Extensions.Options; 17 | 18 | namespace AirDropAnywhere.Cli.Hubs 19 | { 20 | internal class AirDropHub : Hub 21 | { 22 | private readonly AirDropService _service; 23 | private readonly ILogger _logger; 24 | private readonly IOptions _options; 25 | 26 | private static readonly ObjectPool _callbackPool = 27 | new DefaultObjectPool( 28 | new CallbackObjectPoolPolicy(), 5 29 | ); 30 | 31 | public AirDropHub(AirDropService service, ILogger logger, IOptions options) 32 | { 33 | _service = service ?? throw new ArgumentNullException(nameof(service)); 34 | _logger = logger ?? throw new ArgumentNullException(nameof(logger)); 35 | _options = options ?? throw new ArgumentNullException(nameof(options)); 36 | } 37 | 38 | /// 39 | /// Starts a bi-directional stream between the server and the client. 40 | /// 41 | /// 42 | /// of -derived messages 43 | /// from the client. 44 | /// 45 | /// 46 | /// used to cancel the operation. 47 | /// 48 | /// 49 | /// of -derived messages 50 | /// from the server. 51 | /// 52 | public async IAsyncEnumerable StreamAsync(IAsyncEnumerable stream, [EnumeratorCancellation] CancellationToken cancellationToken) 53 | { 54 | var serverChannel = Channel.CreateUnbounded(); 55 | var callbacks = new Dictionary(); 56 | var httpContext = Context.GetHttpContext()!; 57 | var baseUri = new UriBuilder(httpContext.Request.GetEncodedUrl()) 58 | { 59 | Path = "/", 60 | Query = "" 61 | }.Uri; 62 | 63 | var peer = new AirDropHubPeer( 64 | serverChannel.Writer, _logger, baseUri, _options.Value.UploadPath 65 | ); 66 | 67 | // register the peer so that it's advertised as supporting AirDrop 68 | await _service.RegisterPeerAsync(peer); 69 | 70 | try 71 | { 72 | await foreach (var message in FullDuplexStreamAsync(Produce, stream, Consume, cancellationToken)) 73 | { 74 | yield return message; 75 | } 76 | } 77 | finally 78 | { 79 | await _service.UnregisterPeerAsync(peer); 80 | } 81 | 82 | // Iterates any message + callback tuples that are queued into 83 | // our server-side channel, associates the callback with the message's 84 | // unique identifier so that we can handle request/response scenarios 85 | // and yields the message itself back to the caller so it is streamed 86 | // to the client 87 | async IAsyncEnumerable Produce() 88 | { 89 | await foreach (var (message, callback) in serverChannel.Reader.ReadAllAsync(cancellationToken)) 90 | { 91 | if (callback != null) 92 | { 93 | callbacks[message.Id] = callback; 94 | } 95 | 96 | yield return message; 97 | } 98 | } 99 | 100 | // Iterates any messages streamed from the client and, if the ReplyTo 101 | // property is set, uses it to find an associated callback. If found the 102 | // callback is fired, otherwise a warning is logged and the message is ignored. 103 | // 104 | // If the ReplyTo property is _not_ set the message is treated as a new, unsolicited, 105 | // message and is queued to the peer for handling. 106 | async ValueTask Consume(IAsyncEnumerable messages) 107 | { 108 | await foreach (var message in messages.WithCancellation(cancellationToken)) 109 | { 110 | if (message.ReplyTo != null) 111 | { 112 | if (callbacks.Remove(message.ReplyTo, out var callback)) 113 | { 114 | // this message is a reply to another message, notify anything 115 | // awaiting the result instead of dispatching to the peer 116 | // as a new message. 117 | callback.SetResult(message); 118 | } 119 | else 120 | { 121 | _logger.LogWarning("Unexpected reply to message {MessageId}", message.ReplyTo); 122 | } 123 | 124 | continue; 125 | } 126 | 127 | // this was an unsolicited message from the client, have the peer handle it 128 | peer.OnMessage(message); 129 | } 130 | } 131 | } 132 | 133 | private async IAsyncEnumerable FullDuplexStreamAsync( 134 | Func> producer, 135 | IAsyncEnumerable source, 136 | Func, ValueTask> consumer, 137 | [EnumeratorCancellation] CancellationToken cancellationToken 138 | ) 139 | { 140 | using var allDone = CancellationTokenSource.CreateLinkedTokenSource(Context.ConnectionAborted, cancellationToken); 141 | Task? consumed = null; 142 | try 143 | { 144 | consumed = Task.Run( 145 | () => consumer(source).AsTask(), 146 | allDone.Token 147 | ); // note this shares a capture scope 148 | 149 | await foreach (var value in producer().WithCancellation(cancellationToken).ConfigureAwait(false)) 150 | { 151 | yield return value; 152 | } 153 | } 154 | finally 155 | { 156 | // stop the producer, in any exit scenario 157 | allDone.Cancel(); 158 | 159 | if (consumed != null) 160 | { 161 | await consumed.ConfigureAwait(false); 162 | } 163 | } 164 | } 165 | 166 | private readonly struct MessageWithCallback 167 | { 168 | public MessageWithCallback(AirDropHubMessage message, CallbackValueTaskSource? callback) 169 | { 170 | Message = message ?? throw new ArgumentNullException(nameof(message)); 171 | Callback = callback; 172 | } 173 | 174 | public AirDropHubMessage Message { get; } 175 | public CallbackValueTaskSource? Callback { get; } 176 | 177 | public void Deconstruct(out AirDropHubMessage message, out CallbackValueTaskSource? callback) 178 | { 179 | message = Message; 180 | callback = Callback; 181 | } 182 | } 183 | 184 | /// 185 | /// Implementation of that enables 186 | /// a request/response-style conversation to occur over a SignalR full 187 | /// duplex connection to a client. This is used to enable the hub to 188 | /// perform a callback. 189 | /// 190 | private class CallbackValueTaskSource : IValueTaskSource 191 | { 192 | private ManualResetValueTaskSourceCore _valueTaskSource; // mutable struct; do not make this readonly 193 | 194 | public void SetResult(AirDropHubMessage message) => _valueTaskSource.SetResult(message); 195 | 196 | public AirDropHubMessage GetResult(short token) => _valueTaskSource.GetResult(token); 197 | 198 | public ValueTaskSourceStatus GetStatus(short token) => _valueTaskSource.GetStatus(token); 199 | 200 | public void OnCompleted( 201 | Action continuation, 202 | object? state, 203 | short token, 204 | ValueTaskSourceOnCompletedFlags flags 205 | ) => _valueTaskSource.OnCompleted(continuation, state, token, flags); 206 | 207 | public void Reset() => _valueTaskSource.Reset(); 208 | 209 | public async ValueTask TransformAsync(Func transformer) where TMessage : AirDropHubMessage 210 | { 211 | var result = await new ValueTask(this, _valueTaskSource.Version); 212 | if (result is TMessage typedResult) 213 | { 214 | return transformer(typedResult); 215 | } 216 | 217 | throw new InvalidCastException( 218 | $"Cannot convert message of type {result.GetType()} to {typeof(TMessage)}" 219 | ); 220 | } 221 | } 222 | 223 | /// 224 | /// Implementation of that translates from our SignalR 225 | /// message format to the protocol expected by our proxy. 226 | /// 227 | private class AirDropHubPeer : AirDropPeer 228 | { 229 | private readonly ChannelWriter _serverQueue; 230 | private readonly ILogger _logger; 231 | private readonly Uri _baseUri; 232 | private readonly string _basePath; 233 | 234 | public AirDropHubPeer(ChannelWriter serverQueue, ILogger logger, Uri baseUri, string basePath) 235 | { 236 | _serverQueue = serverQueue ?? throw new ArgumentNullException(nameof(serverQueue)); 237 | _logger = logger ?? throw new ArgumentNullException(nameof(logger)); 238 | _baseUri = baseUri ?? throw new ArgumentNullException(nameof(baseUri)); 239 | _basePath = basePath ?? throw new ArgumentNullException(nameof(basePath)); 240 | } 241 | 242 | /// 243 | /// Handles an unsolicited message from the connected client. 244 | /// 245 | internal void OnMessage(AirDropHubMessage message) 246 | { 247 | switch (message) 248 | { 249 | case ConnectMessage connectMessage: 250 | Name = connectMessage.Name; 251 | break; 252 | default: 253 | _logger.LogWarning("Unable to handle message of type {MessageType}", message.GetType()); 254 | break; 255 | } 256 | } 257 | 258 | /// . 259 | public override async ValueTask CanAcceptFilesAsync(AskRequest request) 260 | { 261 | var requestMessage = await AirDropHubMessage.CreateAsync( 262 | (CanAcceptFilesRequestMessage m, AskRequest r) => 263 | { 264 | m.SenderComputerName = r.SenderComputerName; 265 | m.FileIcon = r.FileIcon; 266 | m.Files = r.Files 267 | .Select( 268 | f => new CanAcceptFileMetadata 269 | { 270 | Name = f.FileName, 271 | Type = f.FileType 272 | }) 273 | .ToList(); 274 | 275 | return default; 276 | }, 277 | request 278 | ); 279 | 280 | return await ExecuteAsync( 281 | requestMessage, (CanAcceptFilesResponseMessage x) => x.Accepted 282 | ); 283 | } 284 | 285 | /// . 286 | public override async ValueTask OnFileUploadedAsync(string filePath) 287 | { 288 | // extract the relative path from our upload directory 289 | // and use it to construct the URI that the file will be exposed 290 | // at in the static file provider in Kestrel 291 | var relativePath = Path.GetRelativePath(_basePath, filePath); 292 | var name = Path.GetFileName(filePath); 293 | var uriBuilder = new UriBuilder(_baseUri); 294 | uriBuilder.Path += relativePath.Replace('\\', '/'); 295 | var url = uriBuilder.Uri.ToString(); 296 | 297 | var requestMessage = await AirDropHubMessage.CreateAsync( 298 | (OnFileUploadedRequestMessage m, (string Name, string Url) state) => 299 | { 300 | m.Name = state.Name; 301 | m.Url = state.Url; 302 | return default; 303 | }, 304 | (name, url) 305 | ); 306 | 307 | await ExecuteAsync( 308 | requestMessage, 309 | (OnFileUploadedResponseMessage _) => Void.Value 310 | ); 311 | } 312 | 313 | private async ValueTask ExecuteAsync(TRequest request, Func transformer) 314 | where TRequest : AirDropHubMessage 315 | where TResponse : AirDropHubMessage 316 | { 317 | var callback = _callbackPool.Get(); 318 | try 319 | { 320 | await _serverQueue.WriteAsync(new MessageWithCallback(request, callback)); 321 | return await callback.TransformAsync(transformer); 322 | } 323 | finally 324 | { 325 | _callbackPool.Return(callback); 326 | } 327 | } 328 | 329 | private class Void 330 | { 331 | public static readonly Void Value = new(); 332 | } 333 | } 334 | 335 | /// 336 | /// Pooled object policy that ensures 337 | /// is called when the value is returned to the pool. 338 | /// 339 | private class CallbackObjectPoolPolicy : PooledObjectPolicy 340 | { 341 | public override CallbackValueTaskSource Create() => new(); 342 | 343 | public override bool Return(CallbackValueTaskSource obj) 344 | { 345 | obj.Reset(); 346 | return true; 347 | } 348 | } 349 | } 350 | } -------------------------------------------------------------------------------- /src/AirDropAnywhere.Core/Compression/CpioArchiveReader.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Buffers; 3 | using System.Collections.Generic; 4 | using System.Collections.Immutable; 5 | using System.IO; 6 | using System.IO.Pipelines; 7 | using System.Resources; 8 | using System.Text; 9 | using System.Threading; 10 | using System.Threading.Tasks; 11 | 12 | namespace AirDropAnywhere.Core.Compression 13 | { 14 | /// 15 | /// Implements the code necessary to extract a CPIO archive in OIDC format. 16 | /// 17 | /// 18 | /// See https://manpages.ubuntu.com/manpages/bionic/man5/cpio.5.html for further details 19 | /// on the structure of an OIDC formatted archive. 20 | /// 21 | internal class CpioArchiveReader : IAsyncDisposable 22 | { 23 | private readonly PipeReader _pipeReader; 24 | 25 | private CpioArchiveReader(PipeReader pipeReader) 26 | { 27 | _pipeReader = pipeReader ?? throw new ArgumentNullException(nameof(pipeReader)); 28 | } 29 | 30 | private byte[]? _workingBytes; 31 | private byte[] GetWorkingBytes() => _workingBytes ??= ArrayPool.Shared.Rent(4096); 32 | 33 | public static CpioArchiveReader Create(Stream stream) => new( 34 | PipeReader.Create(stream, new StreamPipeReaderOptions(leaveOpen: true)) 35 | ); 36 | 37 | /// 38 | /// "TRAILER" filename used to indicate we're on the last record. 39 | /// 40 | private static readonly ReadOnlyMemory _trailerValue = new[] 41 | { 42 | (byte) 'T', (byte) 'R', (byte) 'A', (byte) 'I', (byte) 'L', 43 | (byte) 'E', (byte) 'R', (byte) '!', (byte) '!', (byte) '!' 44 | }; 45 | 46 | /// 47 | /// Prefix used for files in the current directory. 48 | /// 49 | private static readonly ReadOnlyMemory _currentDirectoryPrefix = new[] 50 | { 51 | (byte) '.', (byte) '/' 52 | }; 53 | 54 | /// 55 | /// "Filename" used to indicate the current directory. 56 | /// 57 | private static readonly ReadOnlyMemory _currentDirectoryValue = new[] 58 | { 59 | (byte) '.' 60 | }; 61 | 62 | /// 63 | /// "Filename" used to indicate the parent directory. 64 | /// 65 | private static readonly ReadOnlyMemory _parentDirectoryValue = new[] 66 | { 67 | (byte) '.', (byte) '.' 68 | }; 69 | 70 | /// 71 | /// "magic" value at the start of each archive entry. 72 | /// 73 | private static readonly ReadOnlyMemory _magicValue = new[] 74 | { 75 | (byte)'0', (byte)'7', (byte)'0', (byte)'7', (byte)'0', (byte)'7' 76 | }; 77 | 78 | /// 79 | /// Extracts a CPIO archive to the specified output path. 80 | /// 81 | /// 82 | /// Path to extract files to. 83 | /// 84 | /// 85 | /// used to cancel the operation. 86 | /// 87 | public async ValueTask> ExtractAsync(string outputPath, CancellationToken cancellationToken = default) 88 | { 89 | if (outputPath == null) 90 | { 91 | throw new ArgumentNullException(nameof(outputPath)); 92 | } 93 | 94 | if (!Path.IsPathFullyQualified(outputPath)) 95 | { 96 | throw new ArgumentException("Output path must be fully qualified.", nameof(outputPath)); 97 | } 98 | 99 | var extractedFiles = ImmutableList.CreateBuilder(); 100 | var state = new CpioReaderState(); 101 | while (true) 102 | { 103 | var readResult = await _pipeReader.ReadAsync(cancellationToken); 104 | var sequence = readResult.Buffer; 105 | var consumed = default(SequencePosition); 106 | if (!sequence.IsEmpty) 107 | { 108 | ExtractArchiveEntries( 109 | ref sequence, 110 | ref state, 111 | outputPath, 112 | extractedFiles, 113 | out consumed 114 | ); 115 | } 116 | 117 | if (state.Operation == CpioReadOperation.End) 118 | { 119 | // consume the rest of the file, we're done here 120 | await _pipeReader.CompleteAsync(); 121 | break; 122 | } 123 | 124 | if (readResult.IsCompleted) 125 | { 126 | // we reached the end of the file prior to reaching the trailer 127 | // this is an error 128 | await _pipeReader.CompleteAsync( 129 | new InvalidOperationException("Did not find trailer before end of file") 130 | ); 131 | break; 132 | } 133 | 134 | _pipeReader.AdvanceTo(consumed, sequence.End); 135 | } 136 | 137 | return extractedFiles.ToImmutable(); 138 | } 139 | 140 | private void ExtractArchiveEntries( 141 | ref ReadOnlySequence sequence, 142 | ref CpioReaderState state, 143 | string outputPath, 144 | ImmutableList.Builder extractedFiles, 145 | out SequencePosition consumed 146 | ) 147 | { 148 | // attempt to read the next entry's metadata 149 | var sequenceReader = new SequenceReader(sequence); 150 | while (!sequenceReader.End) 151 | { 152 | if (state.Operation == CpioReadOperation.Metadata) 153 | { 154 | if (!sequenceReader.IsNext(_magicValue.Span)) 155 | { 156 | throw new InvalidOperationException("Could not find an archive entry."); 157 | } 158 | 159 | if (sequenceReader.UnreadSequence.Length < CpioEntryMetadata.Length) 160 | { 161 | // we can't yet process the header, return until we have enough data 162 | break; 163 | } 164 | 165 | ReadOnlySpan headerSpan; 166 | var headerSequence = sequenceReader.UnreadSequence.Slice(0, CpioEntryMetadata.Length); 167 | if (headerSequence.IsSingleSegment) 168 | { 169 | headerSpan = headerSequence.FirstSpan; 170 | } 171 | else 172 | { 173 | var workingSpan = GetWorkingBytes().AsSpan(); 174 | headerSequence.CopyTo(workingSpan); 175 | headerSpan = workingSpan[..CpioEntryMetadata.Length]; 176 | } 177 | 178 | // parse the metadata 179 | if (!CpioEntryMetadata.TryCreate(headerSpan, out var error, out var metadata)) 180 | { 181 | throw new InvalidOperationException($"Unable to extract metadata from header. {error}"); 182 | } 183 | 184 | // consume the header 185 | sequenceReader.Advance(CpioEntryMetadata.Length); 186 | state = state.OnMetadataRead(metadata); 187 | } 188 | else if (state.Operation == CpioReadOperation.FileName) 189 | { 190 | // filename includes the NUL (\0) terminator 191 | // so exclude it when creating our string 192 | var fileNameSize = state.Metadata.FileNameSize - 1; 193 | ReadOnlySpan fileNameSpan; 194 | var fileNameSequence = sequenceReader.UnreadSequence.Slice(0, fileNameSize); 195 | if (fileNameSequence.IsSingleSegment) 196 | { 197 | fileNameSpan = fileNameSequence.FirstSpan; 198 | } 199 | else 200 | { 201 | var workingSpan = GetWorkingBytes().AsSpan(); 202 | fileNameSequence.CopyTo(workingSpan); 203 | fileNameSpan = workingSpan[..fileNameSize]; 204 | } 205 | 206 | // trim off any ./ prefixes, they confuse things downstream 207 | if (fileNameSpan.StartsWith(_currentDirectoryPrefix.Span)) 208 | { 209 | fileNameSpan = fileNameSpan.TrimStart(_currentDirectoryPrefix.Span); 210 | } 211 | 212 | if (fileNameSpan.SequenceEqual(_currentDirectoryValue.Span) || fileNameSpan.SequenceEqual(_parentDirectoryValue.Span)) 213 | { 214 | // we've found a current or parent directory entry 215 | // ignore it... 216 | sequenceReader.Advance(state.Metadata.FileNameSize); 217 | sequenceReader.Advance(state.Metadata.FileSize); 218 | state = state.Reset(); 219 | break; 220 | } 221 | 222 | if (fileNameSpan.SequenceEqual(_trailerValue.Span)) 223 | { 224 | // we're found the TRAILER!!! entry 225 | // we've reached the end of the file, exit early 226 | sequenceReader.Advance(state.Metadata.FileNameSize); 227 | sequenceReader.Advance(state.Metadata.FileSize); 228 | state = state.OnTrailerRead(); 229 | break; 230 | } 231 | 232 | var filePath = Encoding.ASCII.GetString(fileNameSpan); 233 | filePath = Path.Join(outputPath, filePath); 234 | if (!Path.GetFullPath(filePath).StartsWith(outputPath, StringComparison.OrdinalIgnoreCase)) 235 | { 236 | // path contains .. and is trying to break out 237 | // of the extraction path - that's a no-no 238 | throw new InvalidOperationException("Unexpected path traversal in filename"); 239 | } 240 | 241 | sequenceReader.Advance(state.Metadata.FileNameSize); 242 | state = state.OnFileNameRead(filePath); 243 | if (state.Metadata.Type == EntryType.File) 244 | { 245 | extractedFiles.Add(filePath); 246 | } 247 | 248 | if (state.Metadata.FileSize == 0) 249 | { 250 | // no bytes to read, skip the entry 251 | state = state.Reset(); 252 | } 253 | } 254 | else if (state.Operation == CpioReadOperation.FileData) 255 | { 256 | // write all data in the sequence upto the expected length of the file 257 | var totalBytesLeft = state.Metadata.FileSize - state.BytesWritten; 258 | var bytesToRead = Math.Min(sequenceReader.UnreadSequence.Length, totalBytesLeft); 259 | foreach (var segment in sequenceReader.UnreadSequence.Slice(0, bytesToRead)) 260 | { 261 | state = state.OnFileDataRead(segment); 262 | } 263 | 264 | sequenceReader.Advance(bytesToRead); 265 | if (state.BytesWritten == state.Metadata.FileSize) 266 | { 267 | // prepare for reading the next entry 268 | state = state.Reset(); 269 | } 270 | } 271 | } 272 | 273 | consumed = sequenceReader.Position; 274 | } 275 | 276 | public ValueTask DisposeAsync() 277 | { 278 | if (_workingBytes != null) 279 | { 280 | ArrayPool.Shared.Return(_workingBytes); 281 | } 282 | 283 | return _pipeReader.CompleteAsync(); 284 | } 285 | 286 | private enum EntryType 287 | { 288 | Directory = 1, 289 | File = 2, 290 | Other = 3, 291 | } 292 | 293 | /// 294 | /// CPIO ODC ASCII format header 295 | /// 296 | private readonly struct CpioEntryMetadata 297 | { 298 | public const int Length = 76; 299 | private CpioEntryMetadata(int fileNameSize, int fileSize, EntryType type) 300 | { 301 | FileNameSize = fileNameSize; 302 | FileSize = fileSize; 303 | Type = type; 304 | } 305 | 306 | public int FileNameSize { get; } 307 | public int FileSize { get; } 308 | public EntryType Type { get; } 309 | 310 | public enum ParseError 311 | { 312 | None, 313 | InvalidBufferSize, 314 | InvalidMagic, 315 | InvalidMode, 316 | InvalidFileNameSize, 317 | InvalidFileSize, 318 | } 319 | 320 | public static bool TryCreate(ReadOnlySpan buffer, out ParseError error, out CpioEntryMetadata metadata) 321 | { 322 | if (buffer.Length != 76) 323 | { 324 | error = ParseError.InvalidBufferSize; 325 | metadata = default; 326 | return false; 327 | } 328 | 329 | var magicValue = buffer[..6]; 330 | if (!magicValue.SequenceEqual(_magicValue.Span)) 331 | { 332 | error = ParseError.InvalidMagic; 333 | metadata = default; 334 | return false; 335 | } 336 | 337 | // we don't really care about much else other than the 338 | // mode, file name & size - parse out the octal strings and convert 339 | // to their underlying uint values 340 | var modeSpan = buffer.Slice(17, 6); 341 | var type = EntryType.Other; 342 | if (!Utils.TryParseOctalToUInt32(modeSpan, out var mode)) 343 | { 344 | error = ParseError.InvalidMode; 345 | metadata = default; 346 | return false; 347 | } 348 | 349 | const uint DirectoryMask = 2048; 350 | const uint FileMask = 4096; 351 | if ((mode & DirectoryMask) != 0) 352 | { 353 | type = EntryType.Directory; 354 | } 355 | else if ((mode & FileMask) != 0) 356 | { 357 | type = EntryType.File; 358 | } 359 | 360 | var nameSizeSpan = buffer.Slice(59, 6); 361 | if (!Utils.TryParseOctalToUInt32(nameSizeSpan, out var nameSize)) 362 | { 363 | error = ParseError.InvalidFileNameSize; 364 | metadata = default; 365 | return false; 366 | } 367 | 368 | var fileSizeSpan = buffer[65..]; 369 | if (!Utils.TryParseOctalToUInt32(fileSizeSpan, out var fileSize)) 370 | { 371 | error = ParseError.InvalidFileSize; 372 | metadata = default; 373 | return false; 374 | } 375 | 376 | error = ParseError.None; 377 | metadata = new CpioEntryMetadata((int)nameSize, (int)fileSize, type); 378 | return true; 379 | } 380 | } 381 | 382 | private enum CpioReadOperation 383 | { 384 | Metadata, 385 | FileName, 386 | FileData, 387 | End, 388 | } 389 | 390 | private readonly struct CpioReaderState 391 | { 392 | private readonly Stream? _outputFile; 393 | 394 | public CpioReadOperation Operation { get; } 395 | public CpioEntryMetadata Metadata { get; } 396 | public long BytesWritten => _outputFile?.Length ?? 0; 397 | 398 | private CpioReaderState(CpioReadOperation operation, CpioEntryMetadata metadata, Stream? outputFile) 399 | { 400 | Operation = operation; 401 | Metadata = metadata; 402 | _outputFile = outputFile; 403 | } 404 | 405 | public CpioReaderState Reset() 406 | { 407 | _outputFile?.Dispose(); 408 | return new CpioReaderState( 409 | CpioReadOperation.Metadata, default, null 410 | ); 411 | } 412 | 413 | public CpioReaderState OnMetadataRead(CpioEntryMetadata metadata) => new( 414 | CpioReadOperation.FileName, metadata, null 415 | ); 416 | 417 | public CpioReaderState OnFileNameRead(string filePath) 418 | { 419 | var metadata = Metadata; 420 | if (metadata.Type == EntryType.File) 421 | { 422 | // make sure any parent directory is created before we extract 423 | Directory.CreateDirectory( 424 | Path.GetDirectoryName(filePath)! 425 | ); 426 | return new( 427 | CpioReadOperation.FileData, metadata, File.Create(filePath) 428 | ); 429 | } 430 | 431 | // other types are not supported 432 | return new( 433 | CpioReadOperation.End, default, null 434 | ); 435 | } 436 | 437 | public CpioReaderState OnFileDataRead(ReadOnlyMemory buffer) 438 | { 439 | _outputFile!.Write(buffer.Span); 440 | return this; 441 | } 442 | 443 | public CpioReaderState OnTrailerRead() => new( 444 | CpioReadOperation.End, default, null 445 | ); 446 | } 447 | } 448 | } -------------------------------------------------------------------------------- /src/AirDropAnywhere.Core/MulticastDns/MulticastDnsServer.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Buffers; 3 | using System.Collections.Generic; 4 | using System.Collections.Immutable; 5 | using System.IO; 6 | using System.Linq; 7 | using System.Net; 8 | using System.Net.NetworkInformation; 9 | using System.Net.Sockets; 10 | using System.Runtime.CompilerServices; 11 | using System.Threading; 12 | using System.Threading.Tasks; 13 | using Enclave.UdpPerf; 14 | using Makaretu.Dns; 15 | using Makaretu.Dns.Resolving; 16 | 17 | namespace AirDropAnywhere.Core.MulticastDns 18 | { 19 | /// 20 | /// Handles advertising mDNS services by responding to matching requests. 21 | /// 22 | internal class MulticastDnsServer 23 | { 24 | private const int MulticastPort = 5353; 25 | // ReSharper disable InconsistentNaming 26 | private static readonly IPAddress MulticastAddressIPv4 = IPAddress.Parse("224.0.0.251"); 27 | private static readonly IPAddress MulticastAddressIPv6 = IPAddress.Parse("FF02::FB"); 28 | private static readonly IPEndPoint MulticastEndpointIPv4 = new(MulticastAddressIPv4, MulticastPort); 29 | private static readonly IPEndPoint MulticastEndpointIPv6 = new(MulticastAddressIPv6, MulticastPort); 30 | // ReSharper restore InconsistentNaming 31 | 32 | private readonly ImmutableArray _networkInterfaces; 33 | private readonly CancellationToken _cancellationToken; 34 | private readonly NameServer _nameServer; 35 | 36 | private ImmutableDictionary? _listeners; 37 | private ImmutableDictionary? _unicastClients; 38 | private ImmutableDictionary<(AddressFamily, int), SocketClient>? _multicastClients; 39 | private List? _listenerTasks; 40 | 41 | public MulticastDnsServer(ImmutableArray networkInterfaces, CancellationToken cancellationToken) 42 | { 43 | if (networkInterfaces.IsDefaultOrEmpty) 44 | { 45 | throw new ArgumentException("Interfaces are required.", nameof(networkInterfaces)); 46 | } 47 | 48 | _networkInterfaces = networkInterfaces; 49 | _cancellationToken = cancellationToken; 50 | _nameServer = new NameServer() 51 | { 52 | Catalog = new Catalog() 53 | }; 54 | } 55 | 56 | public async ValueTask RegisterAsync(MulticastDnsService service) 57 | { 58 | // add services to the name service 59 | // and pre-emptively announce ourselves across our multicast clients 60 | var catalog = _nameServer.Catalog; 61 | var msg = service.ToMessage(); 62 | 63 | catalog.Add( 64 | new PTRRecord 65 | { 66 | DomainName = service.QualifiedServiceName, 67 | Name = MulticastDnsService.Discovery, 68 | TTL = MulticastDnsService.DefaultTTL, 69 | }, 70 | authoritative: true 71 | ); 72 | catalog.Add( 73 | new PTRRecord 74 | { 75 | DomainName = service.QualifiedInstanceName, 76 | Name = service.QualifiedServiceName, 77 | TTL = MulticastDnsService.DefaultTTL, 78 | }, 79 | authoritative: true 80 | ); 81 | 82 | foreach (var resourceRecord in msg.Answers) 83 | { 84 | catalog.Add(resourceRecord, authoritative: true); 85 | } 86 | 87 | if (_multicastClients != null) 88 | { 89 | foreach (var client in _multicastClients.Values) 90 | { 91 | await client.SendAsync(msg); 92 | } 93 | } 94 | } 95 | 96 | public async ValueTask UnregisterAsync(MulticastDnsService service) 97 | { 98 | var catalog = _nameServer.Catalog; 99 | 100 | // remove all services advertised under this name 101 | catalog.TryRemove(service.QualifiedServiceName, out _); 102 | catalog.TryRemove(service.QualifiedInstanceName, out _); 103 | catalog.TryRemove(service.HostName, out _); 104 | 105 | // and pre-emptively announce ourselves with a 0 TTL 106 | var msg = service.ToMessage(); 107 | 108 | foreach (var answer in msg.Answers) 109 | { 110 | answer.TTL = TimeSpan.Zero; 111 | } 112 | 113 | foreach (var additionalRecord in msg.AdditionalRecords) 114 | { 115 | additionalRecord.TTL = TimeSpan.Zero; 116 | } 117 | 118 | if (_multicastClients != null) 119 | { 120 | foreach (var client in _multicastClients.Values) 121 | { 122 | await client.SendAsync(msg); 123 | } 124 | } 125 | } 126 | 127 | public ValueTask StartAsync() 128 | { 129 | var multicastClients = ImmutableDictionary.CreateBuilder<(AddressFamily, int), SocketClient>(); 130 | var unicastClients = ImmutableDictionary.CreateBuilder(); 131 | var listeners = ImmutableDictionary.CreateBuilder(); 132 | foreach (var networkInterface in _networkInterfaces) 133 | { 134 | var interfaceProperties = networkInterface.GetIPProperties(); 135 | // grab the addresses for each interface 136 | // and configure the relevant listeners and clients for each one to handle 137 | // sending and receiving of multicast traffic 138 | var addresses = GetNetworkInterfaceLocalAddresses(networkInterface); 139 | foreach (var address in addresses) 140 | { 141 | if (!listeners.ContainsKey(address.AddressFamily)) 142 | { 143 | listeners.Add(address.AddressFamily, CreateListener(address)); 144 | } 145 | 146 | if (!unicastClients.ContainsKey(address.AddressFamily)) 147 | { 148 | unicastClients.Add(address.AddressFamily, CreateUnicastClient(address)); 149 | } 150 | 151 | var interfaceIndex = 0; 152 | // multicast clients are keyed by both the address family 153 | // and the index of the interface they are associated with. 154 | // When sending a multicast message we enumerate and send to 155 | // _all_ clients. When responding to a multicast message we 156 | // respond using the client associated with the interface index 157 | // that the message was received on, otherwise the sender 158 | // never sees the response. 159 | if (address.AddressFamily == AddressFamily.InterNetwork) 160 | { 161 | interfaceIndex = interfaceProperties.GetIPv4Properties().Index; 162 | } 163 | else if (address.AddressFamily == AddressFamily.InterNetworkV6) 164 | { 165 | interfaceIndex = interfaceProperties.GetIPv6Properties().Index; 166 | } 167 | 168 | multicastClients.Add((address.AddressFamily, interfaceIndex), CreateMulticastClient(address)); 169 | } 170 | } 171 | 172 | _listeners = listeners.ToImmutable(); 173 | _unicastClients = unicastClients.ToImmutable(); 174 | _multicastClients = multicastClients.ToImmutable(); 175 | _listenerTasks = new List(_listeners.Count); 176 | // now we have our listening sockets, hook up background threads 177 | // that listen for messages from each one 178 | foreach (var listener in _listeners.Values) 179 | { 180 | _listenerTasks.Add( 181 | Task.Run( 182 | () => ListenAsync(listener), _cancellationToken 183 | ) 184 | ); 185 | } 186 | return default; 187 | } 188 | 189 | public async ValueTask StopAsync() 190 | { 191 | if (_listenerTasks != null) 192 | { 193 | await Task.WhenAll(_listenerTasks); 194 | } 195 | 196 | if (_multicastClients != null) 197 | { 198 | foreach (var client in _multicastClients.Values) 199 | { 200 | client.Socket.Dispose(); 201 | } 202 | } 203 | 204 | if (_unicastClients != null) 205 | { 206 | foreach (var client in _unicastClients.Values) 207 | { 208 | client.Socket.Dispose(); 209 | } 210 | } 211 | 212 | if (_listeners != null) 213 | { 214 | foreach (var listener in _listeners.Values) 215 | { 216 | listener.Socket.Dispose(); 217 | } 218 | } 219 | 220 | _listeners = null; 221 | _listenerTasks = null; 222 | _multicastClients = null; 223 | _unicastClients = null; 224 | } 225 | 226 | private async Task ListenAsync(SocketListener listener) 227 | { 228 | await foreach (var receiveResult in listener.ReceiveAsync(_cancellationToken)) 229 | { 230 | var request = receiveResult.Message; 231 | if (!request.IsQuery) 232 | { 233 | continue; 234 | } 235 | 236 | // normalize unicast responses 237 | // mDNS uses an additional bit to signify that a unicast response 238 | // is required for a message. This checks for that bit and adjusts 239 | // the query so that it represents the correct data. 240 | // see https://github.com/richardschneider/net-mdns/blob/master/src/ServiceDiscovery.cs#L382-L392 241 | var useUnicast = false; 242 | foreach (var q in request.Questions) 243 | { 244 | if (((ushort) q.Class & 0x8000) != 0) 245 | { 246 | useUnicast = true; 247 | q.Class = (DnsClass) ((ushort) q.Class & 0x7fff); 248 | } 249 | } 250 | 251 | var response = await _nameServer.ResolveAsync(request, _cancellationToken); 252 | if (response.Status != MessageStatus.NoError) 253 | { 254 | // couldn't resolve the request, ignore it 255 | continue; 256 | } 257 | 258 | // All MDNS answers are authoritative and have a transaction 259 | // ID of zero. 260 | response.AA = true; 261 | response.Id = 0; 262 | 263 | // All MDNS answers must not contain any questions. 264 | response.Questions.Clear(); 265 | 266 | var endpoint = receiveResult.Endpoint; 267 | var packetInformation = receiveResult.PacketInformation; 268 | if (useUnicast && _unicastClients!.TryGetValue(endpoint.AddressFamily, out var client)) 269 | { 270 | // a unicast response is needed for this query 271 | // send a response directly to the endpoint that sent it 272 | await client.SendAsync(response, endpoint); 273 | } 274 | else if (!useUnicast && _multicastClients!.TryGetValue((endpoint.AddressFamily, packetInformation.Interface), out client)) 275 | { 276 | // send a multicast response using 277 | // the specific interface that the query was received on 278 | await client.SendAsync(response); 279 | } 280 | } 281 | } 282 | 283 | private static IEnumerable GetNetworkInterfaceLocalAddresses(NetworkInterface networkInterface) 284 | { 285 | return networkInterface 286 | .GetIPProperties() 287 | .UnicastAddresses 288 | .Select(x => x.Address) 289 | .Where(x => x.AddressFamily != AddressFamily.InterNetworkV6 || x.IsIPv6LinkLocal) 290 | ; 291 | } 292 | 293 | private static SocketListener CreateListener(IPAddress ipAddress) 294 | { 295 | IPEndPoint localEndpoint; 296 | if (ipAddress.AddressFamily == AddressFamily.InterNetwork) 297 | { 298 | localEndpoint = new IPEndPoint(IPAddress.Any, MulticastPort); 299 | } 300 | else if (ipAddress.AddressFamily == AddressFamily.InterNetworkV6) 301 | { 302 | localEndpoint = new IPEndPoint(IPAddress.IPv6Any, MulticastPort); 303 | } 304 | else 305 | { 306 | throw new ArgumentException($"Unsupported IP address: {ipAddress}", nameof(ipAddress)); 307 | } 308 | 309 | var socket = new Socket(ipAddress.AddressFamily, SocketType.Dgram, ProtocolType.Udp); 310 | socket.SetAwdlSocketOption(); 311 | socket.SetReuseAddressSocketOption(); 312 | 313 | if (ipAddress.AddressFamily == AddressFamily.InterNetworkV6) 314 | { 315 | socket.SetSocketOption(SocketOptionLevel.IPv6, SocketOptionName.PacketInformation, true); 316 | socket.SetSocketOption(SocketOptionLevel.IPv6, SocketOptionName.AddMembership, new IPv6MulticastOption(MulticastAddressIPv6, ipAddress.ScopeId)); 317 | } 318 | else if (ipAddress.AddressFamily == AddressFamily.InterNetwork) 319 | { 320 | socket.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.PacketInformation, true); 321 | socket.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.AddMembership, new MulticastOption(MulticastAddressIPv4, ipAddress)); 322 | } 323 | socket.Bind(localEndpoint); 324 | return new SocketListener(localEndpoint, socket); 325 | } 326 | 327 | private static SocketClient CreateUnicastClient(IPAddress ipAddress) 328 | { 329 | IPEndPoint localEndpoint; 330 | if (ipAddress.AddressFamily == AddressFamily.InterNetwork) 331 | { 332 | localEndpoint = new IPEndPoint(IPAddress.Any, 0); 333 | } 334 | else if (ipAddress.AddressFamily == AddressFamily.InterNetworkV6) 335 | { 336 | localEndpoint = new IPEndPoint(IPAddress.IPv6Any, 0); 337 | } 338 | else 339 | { 340 | throw new ArgumentException($"Unsupported IP address: {ipAddress}", nameof(ipAddress)); 341 | } 342 | 343 | var socket = new Socket(ipAddress.AddressFamily, SocketType.Dgram, ProtocolType.Udp); 344 | socket.SetAwdlSocketOption(); 345 | socket.SetReuseAddressSocketOption(); 346 | socket.Bind(localEndpoint); 347 | return new SocketClient(localEndpoint, socket); 348 | } 349 | 350 | private static SocketClient CreateMulticastClient(IPAddress ipAddress) 351 | { 352 | IPEndPoint remoteEndpoint; 353 | if (ipAddress.AddressFamily == AddressFamily.InterNetwork) 354 | { 355 | remoteEndpoint = MulticastEndpointIPv4; 356 | } 357 | else if (ipAddress.AddressFamily == AddressFamily.InterNetworkV6) 358 | { 359 | if (ipAddress.ScopeId > 0) 360 | { 361 | remoteEndpoint = new IPEndPoint( 362 | new IPAddress(MulticastAddressIPv6.GetAddressBytes(), ipAddress.ScopeId), 363 | MulticastPort 364 | ); 365 | } 366 | else 367 | { 368 | remoteEndpoint = MulticastEndpointIPv6; 369 | } 370 | } 371 | else 372 | { 373 | throw new ArgumentException($"Unsupported IP address: {ipAddress}", nameof(ipAddress)); 374 | } 375 | 376 | var socket = new Socket(ipAddress.AddressFamily, SocketType.Dgram, ProtocolType.Udp); 377 | socket.SetAwdlSocketOption(); 378 | socket.SetReuseAddressSocketOption(); 379 | socket.Bind(new IPEndPoint(ipAddress, MulticastPort)); 380 | return new SocketClient(remoteEndpoint, socket); 381 | } 382 | 383 | private readonly struct SocketClient 384 | { 385 | public SocketClient(IPEndPoint endpoint, Socket socket) 386 | { 387 | Endpoint = endpoint; 388 | Socket = socket; 389 | } 390 | 391 | public IPEndPoint Endpoint { get; } 392 | public Socket Socket { get; } 393 | 394 | public async ValueTask SendAsync(Message message, IPEndPoint? endpoint = null) 395 | { 396 | // allocate a small buffer for our packets 397 | var buffer = ArrayPool.Shared.Rent(Message.MaxLength); 398 | var bufferMemory = buffer.AsMemory(); 399 | try 400 | { 401 | int length; 402 | await using (var memoryStream = new MemoryStream(buffer, true)) 403 | { 404 | message.Write(memoryStream); 405 | length = (int) memoryStream.Position; 406 | } 407 | await Socket.SendToAsync(endpoint ?? Endpoint, bufferMemory[..length]); 408 | } 409 | finally 410 | { 411 | ArrayPool.Shared.Return(buffer); 412 | } 413 | } 414 | } 415 | 416 | private readonly struct SocketListener 417 | { 418 | public SocketListener(IPEndPoint endpoint, Socket socket) 419 | { 420 | Endpoint = endpoint; 421 | Socket = socket; 422 | } 423 | 424 | public IPEndPoint Endpoint { get; } 425 | public Socket Socket { get; } 426 | 427 | public async IAsyncEnumerable ReceiveAsync([EnumeratorCancellation] CancellationToken cancellationToken) 428 | { 429 | var socket = Socket; 430 | 431 | // ReceiveFromAsync does not support cancellation directly 432 | // so register to close the socket when cancellation occurs 433 | cancellationToken.Register( 434 | () => socket.Close() 435 | ); 436 | 437 | // allocate a small buffer for our packets 438 | var buffer = GC.AllocateArray(Message.MaxLength, true); 439 | var bufferMemory = buffer.AsMemory(); 440 | while (!cancellationToken.IsCancellationRequested) 441 | { 442 | // continually listen for messages from the socket 443 | // decode them and queue them into the receiving channel 444 | UdpSocketExtensions.SocketReceiveResult result; 445 | try 446 | { 447 | result = await socket.ReceiveFromAsync(Endpoint, bufferMemory); 448 | } 449 | catch (SocketException ex) when (ex.SocketErrorCode == SocketError.OperationAborted) 450 | { 451 | // socket was closed 452 | // probably by the cancellation token being cancelled 453 | // try to continue the loop so we exit gracefully 454 | continue; 455 | } 456 | 457 | // ideally this would use a pooled set of message objects 458 | // rather than allocating a new one each time but the underlying 459 | // API doesn't readily support such things 460 | var message = new Message(); 461 | message.Read(buffer, 0, result.ReceivedBytes); 462 | yield return new ReceiveResult(message, (IPEndPoint) result.Endpoint, result.PacketInformation); 463 | } 464 | } 465 | } 466 | 467 | private readonly struct ReceiveResult 468 | { 469 | public ReceiveResult(Message message, IPEndPoint endpoint, IPPacketInformation packetInformation) 470 | { 471 | Message = message; 472 | Endpoint = endpoint; 473 | PacketInformation = packetInformation; 474 | } 475 | 476 | public Message Message { get; } 477 | public IPEndPoint Endpoint { get; } 478 | public IPPacketInformation PacketInformation { get; } 479 | } 480 | } 481 | } --------------------------------------------------------------------------------