├── pt.ico ├── logo.png ├── tube.png ├── .nuget ├── NuGet.exe ├── NuGet.Config ├── .nuget │ └── NuGet.Config └── NuGet.targets ├── Properties ├── launchSettings.json ├── AssemblyInfo.cs ├── Settings.settings └── Settings.Designer.cs ├── ExitCode.cs ├── manifests └── c │ └── CodeWise │ └── PneumaticTube │ └── 1.8.0.0 │ ├── CodeWise.PneumaticTube.yaml │ ├── CodeWise.PneumaticTube.installer.yaml │ └── CodeWise.PneumaticTube.locale.en-US.yaml ├── pneumatictube-package ├── tools │ └── ChocolateyInstall.ps1 └── pneumatictube.portable.nuspec ├── NoProgressDisplay.cs ├── FileToUpload.cs ├── .gitignore ├── license.txt ├── BytesProgressDisplay.cs ├── App.config ├── PercentProgressDisplay.cs ├── PneumaticTube.sln ├── UploadOptions.cs ├── PneumaticTube.csproj ├── DropboxClientExtensions.cs ├── DropboxClientFactory.cs ├── readme.md └── Program.cs /pt.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hartez/PneumaticTube/HEAD/pt.ico -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hartez/PneumaticTube/HEAD/logo.png -------------------------------------------------------------------------------- /tube.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hartez/PneumaticTube/HEAD/tube.png -------------------------------------------------------------------------------- /.nuget/NuGet.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hartez/PneumaticTube/HEAD/.nuget/NuGet.exe -------------------------------------------------------------------------------- /Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "PneumaticTube": { 4 | "commandName": "Project", 5 | "commandLineArgs": "--help" 6 | } 7 | } 8 | } -------------------------------------------------------------------------------- /.nuget/NuGet.Config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.nuget/.nuget/NuGet.Config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /ExitCode.cs: -------------------------------------------------------------------------------- 1 | namespace PneumaticTube 2 | { 3 | internal enum ExitCode 4 | { 5 | Success = 0, 6 | FileNotFound = 2, 7 | AccessDenied = 5, 8 | BadArguments = 160, 9 | FileExists = 80, 10 | Canceled = 1223, 11 | UnknownError = int.MaxValue 12 | } 13 | } -------------------------------------------------------------------------------- /manifests/c/CodeWise/PneumaticTube/1.8.0.0/CodeWise.PneumaticTube.yaml: -------------------------------------------------------------------------------- 1 | # Created using wingetcreate 1.10.3.0 2 | # yaml-language-server: $schema=https://aka.ms/winget-manifest.version.1.10.0.schema.json 3 | 4 | PackageIdentifier: CodeWise.PneumaticTube 5 | PackageVersion: 1.8.0.0 6 | DefaultLocale: en-US 7 | ManifestType: version 8 | ManifestVersion: 1.10.0 9 | -------------------------------------------------------------------------------- /pneumatictube-package/tools/ChocolateyInstall.ps1: -------------------------------------------------------------------------------- 1 | $packageName = 'PneumaticTube.portable' 2 | $url = 'https://github.com/hartez/PneumaticTube/releases/download/v1.8/PneumaticTube.zip' 3 | 4 | $installDir = "$(Split-Path -parent $MyInvocation.MyCommand.Definition)" 5 | Install-ChocolateyZipPackage "$packageName" "$url" "$installDir" -Checksum "1405D4E7B18AD3E9143A91930778288C0582DEEC7D244A357309A62274D2ECE2" -ChecksumType sha256 -------------------------------------------------------------------------------- /NoProgressDisplay.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace PneumaticTube 4 | { 5 | internal class NoProgressDisplay(long fileSize, bool quiet) : IProgress 6 | { 7 | public void Report(long value) 8 | { 9 | if(value >= fileSize) 10 | { 11 | if(!quiet) 12 | { 13 | Console.Write("Finished\n"); 14 | } 15 | } 16 | } 17 | } 18 | } -------------------------------------------------------------------------------- /FileToUpload.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | 3 | namespace PneumaticTube 4 | { 5 | internal class FileToUpload(string path) 6 | { 7 | public string FullPath { get; } = Path.GetFullPath(path); 8 | public string Name { get; } = Path.GetFileName(path); 9 | public string Subfolder { get; } 10 | 11 | public FileToUpload(string path, string source) : this(path) 12 | { 13 | Subfolder = Path.GetDirectoryName(Path.GetRelativePath(source, path)).Replace("\\", "/"); 14 | } 15 | } 16 | } -------------------------------------------------------------------------------- /Properties/AssemblyInfo.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using System.Runtime.InteropServices; 3 | [assembly: AssemblyTrademark("")] 4 | [assembly: AssemblyCulture("")] 5 | 6 | // Setting ComVisible to false makes the types in this assembly not visible 7 | // to COM components. If you need to access a type in this assembly from 8 | // COM, set the ComVisible attribute to true on that type. 9 | 10 | [assembly: ComVisible(false)] 11 | 12 | // The following GUID is for the ID of the typelib if this project is exposed to COM 13 | 14 | [assembly: Guid("2502610f-3a6b-45ab-816e-81e2347d4e33")] 15 | -------------------------------------------------------------------------------- /Properties/Settings.settings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /manifests/c/CodeWise/PneumaticTube/1.8.0.0/CodeWise.PneumaticTube.installer.yaml: -------------------------------------------------------------------------------- 1 | # Created using wingetcreate 1.10.3.0 2 | # yaml-language-server: $schema=https://aka.ms/winget-manifest.installer.1.10.0.schema.json 3 | 4 | PackageIdentifier: CodeWise.PneumaticTube 5 | PackageVersion: 1.8.0.0 6 | InstallerType: zip 7 | NestedInstallerType: portable 8 | NestedInstallerFiles: 9 | - RelativeFilePath: PneumaticTube.exe 10 | PortableCommandAlias: pneumatictube 11 | ArchiveBinariesDependOnPath: true 12 | Installers: 13 | - Architecture: neutral 14 | InstallerUrl: https://github.com/hartez/PneumaticTube/releases/download/v1.8/PneumaticTube.zip 15 | InstallerSha256: 1405D4E7B18AD3E9143A91930778288C0582DEEC7D244A357309A62274D2ECE2 16 | ManifestType: installer 17 | ManifestVersion: 1.10.0 18 | Dependencies: 19 | PackageDependencies: 20 | - PackageIdentifier: Microsoft.DotNet.Runtime.10 21 | -------------------------------------------------------------------------------- /manifests/c/CodeWise/PneumaticTube/1.8.0.0/CodeWise.PneumaticTube.locale.en-US.yaml: -------------------------------------------------------------------------------- 1 | # Created using wingetcreate 1.10.3.0 2 | # yaml-language-server: $schema=https://aka.ms/winget-manifest.defaultLocale.1.10.0.schema.json 3 | 4 | PackageIdentifier: CodeWise.PneumaticTube 5 | PackageVersion: 1.8.0.0 6 | PackageLocale: en-US 7 | Publisher: CodeWise LLC 8 | PublisherUrl: https://github.com/hartez/PneumaticTube 9 | PublisherSupportUrl: https://github.com/hartez/PneumaticTube/issues 10 | LicenseUrl: https://github.com/hartez/PneumaticTube/blob/main/license.txt 11 | Author: E.Z. Hart 12 | PackageName: PneumaticTube 13 | License: MIT License 14 | ShortDescription: Command line Dropbox uploader for Windows 15 | Moniker: pneumatictube 16 | Tags: 17 | - console 18 | - command-line 19 | - dropbox 20 | - utilities 21 | - cli 22 | - pneumatictube 23 | ManifestType: defaultLocale 24 | ManifestVersion: 1.10.0 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | apikeys.txt 2 | 3 | #OS junk files 4 | [Tt]humbs.db 5 | *.DS_Store 6 | 7 | #Visual Studio files 8 | *.[Oo]bj 9 | *.user 10 | *.aps 11 | *.pch 12 | *.vspscc 13 | *.vssscc 14 | *_i.c 15 | *_p.c 16 | *.ncb 17 | *.suo 18 | *.tlb 19 | *.tlh 20 | *.bak 21 | *.[Cc]ache 22 | *.ilk 23 | *.log 24 | *.lib 25 | *.sbr 26 | *.sdf 27 | *.opensdf 28 | *.unsuccessfulbuild 29 | ipch/ 30 | [Oo]bj/ 31 | [Bb]in 32 | [Dd]ebug*/ 33 | [Rr]elease*/ 34 | Ankh.NoLoad 35 | 36 | #MonoDevelop 37 | *.pidb 38 | *.userprefs 39 | 40 | #Tooling 41 | _ReSharper*/ 42 | *.resharper 43 | [Tt]est[Rr]esult* 44 | *.sass-cache 45 | 46 | #Project files 47 | [Bb]uild/ 48 | 49 | #Subversion files 50 | .svn 51 | 52 | # Office Temp Files 53 | ~$* 54 | 55 | # vim Temp Files 56 | *~ 57 | 58 | #NuGet 59 | packages/ 60 | *.nupkg 61 | 62 | #ncrunch 63 | *ncrunch* 64 | *crunch*.local.xml 65 | 66 | # visual studio database projects 67 | *.dbmdl 68 | 69 | #Test files 70 | *.testsettings 71 | /.vs/PneumaticTube/project-colors.json 72 | 73 | .vs -------------------------------------------------------------------------------- /license.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2025 CodeWise LLC and E.Z. Hart 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /BytesProgressDisplay.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Security; 4 | 5 | namespace PneumaticTube 6 | { 7 | internal class BytesProgressDisplay : IProgress 8 | { 9 | private readonly bool _consoleCanReportProgress; 10 | private readonly long _fileSize; 11 | 12 | public BytesProgressDisplay(long fileSize) 13 | { 14 | _fileSize = fileSize; 15 | _consoleCanReportProgress = true; 16 | 17 | try 18 | { 19 | // This will throw an exception if we're running in ISE 20 | var top = Console.CursorTop; 21 | } 22 | catch(SecurityException) 23 | { 24 | // No permission to mess with the console, 25 | _consoleCanReportProgress = false; 26 | } 27 | catch(IOException) 28 | { 29 | // This console doesn't allow position setting 30 | _consoleCanReportProgress = false; 31 | } 32 | } 33 | 34 | public void Report(long value) 35 | { 36 | if(_consoleCanReportProgress) 37 | { 38 | Console.SetCursorPosition(0, Console.CursorTop); 39 | Console.Write($"{value} of {_fileSize} uploaded."); 40 | } 41 | 42 | if(value >= _fileSize) 43 | { 44 | if(!_consoleCanReportProgress) 45 | { 46 | Console.Write($"{value} of {_fileSize} uploaded."); 47 | } 48 | 49 | Console.Write("\n"); 50 | } 51 | } 52 | } 53 | } -------------------------------------------------------------------------------- /App.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |
6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 34 | 35 | -------------------------------------------------------------------------------- /PercentProgressDisplay.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Security; 4 | 5 | namespace PneumaticTube 6 | { 7 | internal class PercentProgressDisplay : IProgress 8 | { 9 | private readonly bool _consoleCanReportProgress; 10 | private readonly long _fileSize; 11 | 12 | public PercentProgressDisplay(long fileSize) 13 | { 14 | _fileSize = fileSize; 15 | _consoleCanReportProgress = true; 16 | 17 | try 18 | { 19 | // This will throw an exception if we're running in ISE 20 | var top = Console.CursorTop; 21 | } 22 | catch(SecurityException) 23 | { 24 | // No permission to mess with the console, 25 | _consoleCanReportProgress = false; 26 | } 27 | catch(IOException) 28 | { 29 | // This console doesn't allow position setting 30 | _consoleCanReportProgress = false; 31 | } 32 | } 33 | 34 | public void Report(long value) 35 | { 36 | long percent = 0; 37 | 38 | if(_fileSize > 0) 39 | { 40 | percent = 100*value/_fileSize; 41 | } 42 | 43 | if(_consoleCanReportProgress) 44 | { 45 | Console.SetCursorPosition(0, Console.CursorTop); 46 | Console.Write($"{percent}% complete."); 47 | } 48 | 49 | if(percent >= 100) 50 | { 51 | if(!_consoleCanReportProgress) 52 | { 53 | Console.Write($"{percent}% complete."); 54 | } 55 | 56 | Console.Write("\n"); 57 | } 58 | } 59 | } 60 | } -------------------------------------------------------------------------------- /PneumaticTube.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio 2013 4 | VisualStudioVersion = 12.0.31101.0 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PneumaticTube", "PneumaticTube.csproj", "{C59E7143-AC6C-4CC8-9A7F-16B9846FF929}" 7 | EndProject 8 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".nuget", ".nuget", "{4C9956E4-1BF9-4172-B0DC-8E4247D7C666}" 9 | ProjectSection(SolutionItems) = preProject 10 | .nuget\NuGet.Config = .nuget\NuGet.Config 11 | .nuget\NuGet.exe = .nuget\NuGet.exe 12 | .nuget\NuGet.targets = .nuget\NuGet.targets 13 | EndProjectSection 14 | EndProject 15 | Global 16 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 17 | Debug|Any CPU = Debug|Any CPU 18 | Debug|Mixed Platforms = Debug|Mixed Platforms 19 | Debug|x86 = Debug|x86 20 | Release|Any CPU = Release|Any CPU 21 | Release|Mixed Platforms = Release|Mixed Platforms 22 | Release|x86 = Release|x86 23 | EndGlobalSection 24 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 25 | {C59E7143-AC6C-4CC8-9A7F-16B9846FF929}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 26 | {C59E7143-AC6C-4CC8-9A7F-16B9846FF929}.Debug|Any CPU.Build.0 = Debug|Any CPU 27 | {C59E7143-AC6C-4CC8-9A7F-16B9846FF929}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU 28 | {C59E7143-AC6C-4CC8-9A7F-16B9846FF929}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU 29 | {C59E7143-AC6C-4CC8-9A7F-16B9846FF929}.Debug|x86.ActiveCfg = Debug|Any CPU 30 | {C59E7143-AC6C-4CC8-9A7F-16B9846FF929}.Release|Any CPU.ActiveCfg = Release|Any CPU 31 | {C59E7143-AC6C-4CC8-9A7F-16B9846FF929}.Release|Any CPU.Build.0 = Release|Any CPU 32 | {C59E7143-AC6C-4CC8-9A7F-16B9846FF929}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU 33 | {C59E7143-AC6C-4CC8-9A7F-16B9846FF929}.Release|Mixed Platforms.Build.0 = Release|Any CPU 34 | {C59E7143-AC6C-4CC8-9A7F-16B9846FF929}.Release|x86.ActiveCfg = Release|Any CPU 35 | EndGlobalSection 36 | GlobalSection(SolutionProperties) = preSolution 37 | HideSolutionNode = FALSE 38 | EndGlobalSection 39 | EndGlobal 40 | -------------------------------------------------------------------------------- /UploadOptions.cs: -------------------------------------------------------------------------------- 1 | using CommandLine; 2 | using System; 3 | 4 | namespace PneumaticTube 5 | { 6 | internal class UploadOptions 7 | { 8 | // Default to the root path 9 | private string _dropboxPath = "/"; 10 | 11 | [Option('f', "file", Required = true, HelpText = "The path of the local file to upload. If this is a folder, the contents of the folder will be uploaded to the destination.")] 12 | public string LocalPath { get; set; } 13 | 14 | [Option('p', "path", Required = false, HelpText = "The destination folder path in Dropbox")] 15 | public string DropboxPath 16 | { 17 | get { return _dropboxPath; } 18 | set 19 | { 20 | if (!value.StartsWith('/')) 21 | { 22 | value = $"/{value}"; 23 | } 24 | 25 | _dropboxPath = value; 26 | } 27 | } 28 | 29 | [Option('r', "reset", Required = false, HelpText = "Force PneumaticTube to re-authorize with Dropbox")] 30 | public bool Reset { get; set; } 31 | 32 | [Option('b', "bytes", Required = false, 33 | HelpText = "Display progress in bytes instead of percentage when using chunked uploading")] 34 | public bool Bytes { get; set; } 35 | 36 | [Option('c', "chunked", Required = false, HelpText = "Force chunked uploading")] 37 | public bool Chunked { get; set; } 38 | 39 | [Option('q', "quiet", Required = false, HelpText = "Suppress all output")] 40 | public bool Quiet { get; set; } 41 | 42 | [Option('n', "noprogress", Required = false, HelpText = "Suppress progress output when using chunked uploading")] 43 | public bool NoProgress { get; set; } 44 | 45 | [Option('k', "chunksize", Required = false, HelpText = "Chunk size (in kilobytes) to use during chunked uploading. Defaults to 1024, minimum is 128.")] 46 | public int ChunkSizeInKilobytes { get; set; } = DropboxClientExtensions.DefaultChunkSizeInKilobytes; 47 | 48 | public int ChunkSize => Math.Max(ChunkSizeInKilobytes * 1024, DropboxClientExtensions.MinimumChunkSize); 49 | 50 | [Option('t', "timeout", Required = false, HelpText = "HTTP Timeout (in seconds) for upload operations. Defaults to 100.")] 51 | public int TimeoutSeconds { get; set; } = DropboxClientExtensions.DefaultTimeoutInSeconds; 52 | 53 | [Option('s', "recursive", Required = false, HelpText = "When uploading a folder, recursively upload all subfolders")] 54 | public bool Recursive{ get; set; } 55 | } 56 | } -------------------------------------------------------------------------------- /pneumatictube-package/pneumatictube.portable.nuspec: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | pneumatictube.portable 6 | PneumaticTube (Portable) 7 | 1.8.0.0 8 | E.Z. Hart 9 | E.Z. Hart 10 | Command line Dropbox uploader for Windows 11 | This application provides command line uploading to Dropbox in Windows. For usage info, see the readme at https://github.com/hartez/PneumaticTube 12 | https://github.com/hartez/PneumaticTube 13 | https://github.com/hartez/PneumaticTube 14 | https://github.com/hartez/PneumaticTube/issues 15 | cli commandline dropbox 16 | Copyright 2025 CodeWise LLC and E.Z. Hart 17 | https://github.com/hartez/PneumaticTube/blob/master/license.txt 18 | false 19 | https://rawcdn.githack.com/hartez/PneumaticTube/54ec1f0cdc7579e30d98f65bea7b755cfeaf717e/logo.png 20 | 21 | Version 1.8: 22 | Update to .NET 10 23 | Add recursive folder uploads 24 | Version 1.7: 25 | Add option to timeout for HTTP client 26 | Version 1.6: 27 | Add option to set chunk size for chunked uploading 28 | Version 1.5: 29 | Fix for https://github.com/hartez/PneumaticTube/issues/32 30 | Version 1.4: 31 | Fix for https://github.com/hartez/PneumaticTube/issues/27 32 | Fix for https://github.com/hartez/PneumaticTube/issues/24 33 | Version 1.3: 34 | Dropbox API v2 support 35 | Version 1.2: 36 | Fix for https://github.com/hartez/PneumaticTube/issues/14 37 | Fix for https://github.com/hartez/PneumaticTube/issues/12 38 | Added quiet option to suppress console output 39 | Added noprogress option to suppress progress reporting 40 | Added folder uploading 41 | Version 1.1: 42 | Added chunked uploading for large files 43 | 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /Properties/Settings.Designer.cs: -------------------------------------------------------------------------------- 1 | //------------------------------------------------------------------------------ 2 | // 3 | // This code was generated by a tool. 4 | // Runtime Version:4.0.30319.42000 5 | // 6 | // Changes to this file may cause incorrect behavior and will be lost if 7 | // the code is regenerated. 8 | // 9 | //------------------------------------------------------------------------------ 10 | 11 | namespace PneumaticTube.Properties { 12 | 13 | 14 | [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] 15 | [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "17.14.0.0")] 16 | internal sealed partial class Settings : global::System.Configuration.ApplicationSettingsBase { 17 | 18 | private static Settings defaultInstance = ((Settings)(global::System.Configuration.ApplicationSettingsBase.Synchronized(new Settings()))); 19 | 20 | public static Settings Default { 21 | get { 22 | return defaultInstance; 23 | } 24 | } 25 | 26 | [global::System.Configuration.UserScopedSettingAttribute()] 27 | [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] 28 | [global::System.Configuration.DefaultSettingValueAttribute("")] 29 | public string ACCESS_TOKEN { 30 | get { 31 | return ((string)(this["ACCESS_TOKEN"])); 32 | } 33 | set { 34 | this["ACCESS_TOKEN"] = value; 35 | } 36 | } 37 | 38 | [global::System.Configuration.UserScopedSettingAttribute()] 39 | [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] 40 | [global::System.Configuration.DefaultSettingValueAttribute("")] 41 | public string REFRESH_TOKEN { 42 | get { 43 | return ((string)(this["REFRESH_TOKEN"])); 44 | } 45 | set { 46 | this["REFRESH_TOKEN"] = value; 47 | } 48 | } 49 | 50 | [global::System.Configuration.UserScopedSettingAttribute()] 51 | [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] 52 | [global::System.Configuration.DefaultSettingValueAttribute("")] 53 | public string TOKEN_EXPIRATION { 54 | get { 55 | return ((string)(this["TOKEN_EXPIRATION"])); 56 | } 57 | set { 58 | this["TOKEN_EXPIRATION"] = value; 59 | } 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /PneumaticTube.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net10.0 4 | Exe 5 | .\ 6 | true 7 | PneumaticTube 8 | Command line Dropbox uploader for Windows 9 | CodeWise LLC 10 | PneumaticTube 11 | Copyright © CodeWise LLC and E.Z. Hart 2025 12 | 1.8.0.0 13 | 1.8.0.0 14 | 1.8.0.0 15 | false 16 | pt.ico 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | True 31 | True 32 | Resources.resx 33 | 34 | 35 | True 36 | True 37 | Settings.settings 38 | 39 | 40 | 41 | 42 | SettingsSingleFileGenerator 43 | Settings.Designer.cs 44 | 45 | 46 | 47 | 48 | Always 49 | 50 | 51 | 52 | 53 | ResXFileCodeGenerator 54 | Resources.Designer.cs 55 | 56 | 57 | -------------------------------------------------------------------------------- /DropboxClientExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | using Dropbox.Api; 6 | using Dropbox.Api.Files; 7 | 8 | namespace PneumaticTube 9 | { 10 | internal static class DropboxClientExtensions 11 | { 12 | public const int ChunkedThreshold = 150 * 1024 * 1024; 13 | public const int MinimumChunkSizeInKilobytes = 128; 14 | public const int MinimumChunkSize = MinimumChunkSizeInKilobytes * 1024; 15 | public const int DefaultChunkSizeInKilobytes = 1024; 16 | public const int DefaultTimeoutInSeconds = 100; 17 | 18 | private static string CombinePath(string folder, string fileName) 19 | { 20 | // We can't use Path.Combine here because we'll end up with the Windows separator ("\") and 21 | // we need the forward slash ("/") 22 | 23 | if (folder == "/") 24 | { 25 | return $"/{fileName}"; 26 | } 27 | 28 | return $"{folder}/{fileName}"; 29 | } 30 | 31 | public static async Task Upload(this DropboxClient client, string folder, string fileName, Stream fs) 32 | { 33 | var fullDestinationPath = CombinePath(folder, fileName); 34 | 35 | return await client.Files.UploadAsync(fullDestinationPath, WriteMode.Overwrite.Instance, body: fs); 36 | } 37 | 38 | public static async Task UploadChunked(this DropboxClient client, 39 | string folder, string fileName, Stream fs, IProgress progress, int chunkSize, CancellationToken cancellationToken) 40 | { 41 | int chunks = (int)Math.Ceiling((double)fs.Length / chunkSize); 42 | 43 | byte[] buffer = new byte[chunkSize]; 44 | string sessionId = null; 45 | 46 | FileMetadata resultMetadata = null; 47 | var fullDestinationPath = CombinePath(folder, fileName); 48 | 49 | for (var i = 0; i < chunks; i++) 50 | { 51 | cancellationToken.ThrowIfCancellationRequested(); 52 | 53 | var bytesRead = fs.Read(buffer, 0, chunkSize); 54 | 55 | using var memStream = new MemoryStream(buffer, 0, bytesRead); 56 | 57 | if (i == 0) 58 | { 59 | var result = await client.Files.UploadSessionStartAsync(body: memStream); 60 | sessionId = result.SessionId; 61 | } 62 | else 63 | { 64 | UploadSessionCursor cursor = new UploadSessionCursor(sessionId, (ulong)(chunkSize * i)); 65 | 66 | if (i == chunks - 1) 67 | { 68 | resultMetadata = await client.Files.UploadSessionFinishAsync(cursor, new CommitInfo(fullDestinationPath, WriteMode.Overwrite.Instance), body: memStream); 69 | 70 | if (!cancellationToken.IsCancellationRequested) 71 | { 72 | progress.Report(fs.Length); 73 | } 74 | } 75 | else 76 | { 77 | await client.Files.UploadSessionAppendV2Async(cursor, body: memStream); 78 | if (!cancellationToken.IsCancellationRequested) 79 | { 80 | progress.Report(i * chunkSize); 81 | } 82 | } 83 | } 84 | } 85 | 86 | return resultMetadata; 87 | } 88 | } 89 | } -------------------------------------------------------------------------------- /DropboxClientFactory.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics; 3 | using System.Threading.Tasks; 4 | using Dropbox.Api; 5 | using PneumaticTube.Properties; 6 | 7 | namespace PneumaticTube 8 | { 9 | internal static class DropboxClientFactory 10 | { 11 | const string _appkey = "ii29ofre0mjt9vf"; 12 | 13 | internal static void ResetAuthentication() 14 | { 15 | Settings.Default.ACCESS_TOKEN = string.Empty; 16 | Settings.Default.REFRESH_TOKEN = string.Empty; 17 | Settings.Default.TOKEN_EXPIRATION = string.Empty; 18 | Settings.Default.Save(); 19 | } 20 | 21 | private static void PersistTokens(OAuth2Response response) 22 | { 23 | Settings.Default.ACCESS_TOKEN = response.AccessToken; 24 | Settings.Default.REFRESH_TOKEN = response.RefreshToken; 25 | Settings.Default.TOKEN_EXPIRATION = response.ExpiresAt.ToString() ?? string.Empty; 26 | Settings.Default.Save(); 27 | } 28 | 29 | private static bool LoadTokens(out TokenResult result) 30 | { 31 | var refreshToken = Settings.Default.REFRESH_TOKEN; 32 | 33 | if(string.IsNullOrEmpty(refreshToken)) 34 | { 35 | result = new TokenResult(string.Empty, string.Empty, null); 36 | return false; 37 | } 38 | 39 | var accessToken = Settings.Default.ACCESS_TOKEN; 40 | var hasExpiration = DateTime.TryParse(Settings.Default.TOKEN_EXPIRATION, out DateTime expiresAt); 41 | 42 | result = new TokenResult(accessToken, refreshToken, hasExpiration ? expiresAt : null); 43 | return true; 44 | } 45 | 46 | public static async Task CreateDropboxClient(int timeoutSeconds) 47 | { 48 | var result = await GetAccessTokens(); 49 | 50 | var config = new DropboxClientConfig("PneumaticTube/2") 51 | { 52 | HttpClient = new System.Net.Http.HttpClient 53 | { 54 | Timeout = TimeSpan.FromSeconds(timeoutSeconds) 55 | } 56 | }; 57 | 58 | if(result.ExpiresAt.HasValue) 59 | { 60 | return new DropboxClient(oauth2AccessToken: result.AccessToken, oauth2RefreshToken: result.RefreshToken, 61 | oauth2AccessTokenExpiresAt: result.ExpiresAt.Value, appKey: _appkey, config: config); 62 | } 63 | 64 | return new DropboxClient(oauth2RefreshToken: result.RefreshToken, appKey: _appkey, config: config); 65 | } 66 | 67 | private static async Task GetAccessTokens() 68 | { 69 | if(LoadTokens(out TokenResult tokens)) 70 | { 71 | return tokens; 72 | } 73 | 74 | Console.WriteLine( 75 | "You'll need to authorize this account with PneumaticTube; a browser window will now open asking you to log into Dropbox and allow the app. When you've done that, you'll be given an access key. Enter the key here and hit Enter:"); 76 | 77 | var oauthFlow = new PKCEOAuthFlow(); 78 | 79 | // Pop open the authorization page in the default browser 80 | var url = oauthFlow.GetAuthorizeUri(OAuthResponseType.Code, _appkey, tokenAccessType: TokenAccessType.Offline); 81 | 82 | using (Process p = new()) 83 | { 84 | p.StartInfo.FileName = url.ToString(); 85 | p.StartInfo.UseShellExecute = true; 86 | p.Start(); 87 | } 88 | 89 | // Wait for the user to enter the code 90 | var code = Console.ReadLine(); 91 | 92 | var response = await oauthFlow.ProcessCodeFlowAsync(code, _appkey); 93 | 94 | // Save the token 95 | PersistTokens(response); 96 | 97 | return new TokenResult(response.AccessToken, response.RefreshToken, response.ExpiresAt); 98 | } 99 | 100 | record TokenResult(string AccessToken, string RefreshToken, DateTime? ExpiresAt); 101 | } 102 | } -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # PneumaticTube 2 | 3 | ## Important Update! 4 | 5 | Versions prior to 1.8 will stop working as of January 1, 2026 due to changes Dropbox is making to its infrastructure. You'll need to update to 1.8 or higher to continue using PneumaticTube. Version 1.8 bumps the required .NET version to .NET 10. 6 | 7 | ## Command line Dropbox uploader for Windows 8 | 9 | ![Prague Pneumatic Post](http://upload.wikimedia.org/wikipedia/commons/thumb/f/fa/Hlavn%C3%AD-panel.jpg/320px-Hlavn%C3%AD-panel.jpg) 10 | 11 | ### Usage 12 | 13 | `pneumatictube -f -p ` 14 | 15 | Uploads the specified file to the specified path in Dropbox. The `-f` option can also point to a folder, in which case each file in the folder will be uploaded to Dropbox. By default, only the files in the specified folder are uploaded; use the `-s` option to recursively upload the child folders. 16 | 17 | For example: 18 | 19 | `pneumatictube -f .\report.txt -p /docs` 20 | 21 | would upload `report.txt` to the `docs` folder in the Dropbox account. 22 | 23 | ### Options 24 | 25 | * `-f` (required) The location of the file to upload 26 | * `-p` The destination path in Dropbox (if left blank, will default to your Dropbox root) 27 | * `-r` Force re-authorization with Dropbox 28 | * `-c` Force chunked uploading 29 | * `-b` Display progress in bytes instead of percentage when using chunked uploading 30 | * `-q` Suppress all output (except errors) 31 | * `-n` Suppress progress reporting during chunked uploading 32 | * `-k` Chunk size (in kilobytes) to use during chunked uploading 33 | * `-t` Timeout (in seconds) for HTTP connections 34 | * `-s` If the location to upload is a folder, recursively upload child folders 35 | 36 | ### Authorization 37 | 38 | The first time you run PneumaticTube it will open a browser and ask you to authorize it for your Dropbox account. 39 | 40 | If you ever want to deauthorize it (for example, to authorize it for a different account), you can run it with the `-r` (reset) option. 41 | 42 | ### Chunked Uploading 43 | 44 | Dropbox requires chunked uploading (uploading the file in many small parts, instead of one big blob) for files above 150 MB. Pneumatictube will automatically use chunked uploading for files which require it. For smaller files, you can specify the `-c` option to force chunked uploading. This is useful if you want a progress indicator during the upload. 45 | 46 | If you specify the `-c` option, you can also use the `-b` option to specify that you want your progress updates in bytes instead of percentage (the default), or `-n` to suppress progress reporting. 47 | 48 | The `-k` option allows you to specify the chunk size (in kilobytes) to use during chunked uploading. The default is 1024, and the minimum is 128. If you are uploading very large files, you may find a significant speed boost by increasing the chunk size so the file is uploaded in fewer chunks. 49 | 50 | ### Installation 51 | 52 | If you're not into building the project from source, you can download the [latest release](https://github.com/hartez/PneumaticTube/releases) as a `.zip`. 53 | 54 | If you're a [Chocolatey](https://chocolatey.org/) user, it's available as a [package](https://chocolatey.org/packages/pneumatictube.portable). Just run `choco install pneumatictube.portable` and you should be good to go. 55 | 56 | If you're installing from Chocolatey or just unpacking the `.zip`, you'll need to install the .NET 10 Runtime yourself. 57 | 58 | If you prefer [WinGet](https://learn.microsoft.com/en-us/windows/package-manager/winget/), the package Id is "CodeWise.PneumaticTube". Just run `winget install CodeWise.PneumaticTube`. The WinGet package will also install the .NET 10 Runtime package. 59 | 60 | ### Notes 61 | 62 | This is built on the [.NET SDK for the Dropbox API v2](https://github.com/dropbox/dropbox-sdk-dotnet) and on [Command Line Parser](https://github.com/gsscoder/commandline). I basically just needed an easy way for a TeamCity server to push artifacts out to a Dropbox folder, and I didn't like all the awkward "run Dropbox as a service" hacks out there. 63 | 64 | ----- 65 | 66 | Image Credit: 67 | By Serych at cs.wikipedia [Public domain], from Wikimedia Commons 68 | -------------------------------------------------------------------------------- /Program.cs: -------------------------------------------------------------------------------- 1 | using CommandLine; 2 | using Dropbox.Api; 3 | using Dropbox.Api.Files; 4 | using System; 5 | using System.Collections.Generic; 6 | using System.IO; 7 | using System.Linq; 8 | using System.Threading; 9 | using System.Threading.Tasks; 10 | 11 | namespace PneumaticTube 12 | { 13 | internal class Program 14 | { 15 | static bool ShowCancelHelp = true; 16 | 17 | private static async Task Main(string[] args) 18 | { 19 | return await Parser.Default.ParseArguments(args) 20 | .MapResult(RunAndReturnExitCode, BadArgs); 21 | } 22 | 23 | static async Task RunAndReturnExitCode(UploadOptions options) 24 | { 25 | if (options.Reset) 26 | { 27 | DropboxClientFactory.ResetAuthentication(); 28 | } 29 | 30 | var source = Path.GetFullPath(options.LocalPath); 31 | 32 | if (!File.Exists(source) && !Directory.Exists(source)) 33 | { 34 | Console.WriteLine("Source does not exist."); 35 | return (int)ExitCode.FileNotFound; 36 | } 37 | 38 | // Fix up Dropbox path (fix Windows-style slashes) 39 | options.DropboxPath = options.DropboxPath.Replace(@"\", "/"); 40 | 41 | var exitCode = ExitCode.UnknownError; 42 | 43 | var cts = new CancellationTokenSource(); 44 | 45 | Console.CancelKeyPress += (s, e) => 46 | { 47 | e.Cancel = true; 48 | cts.Cancel(); 49 | }; 50 | 51 | try 52 | { 53 | var files = GetFiles(source, options); 54 | 55 | var client = await DropboxClientFactory.CreateDropboxClient(options.TimeoutSeconds); 56 | await Upload(files, options, client, cts.Token); 57 | exitCode = ExitCode.Success; 58 | } 59 | catch (OperationCanceledException) 60 | { 61 | Output("\nUpload canceled", options); 62 | 63 | exitCode = ExitCode.Canceled; 64 | } 65 | catch (AggregateException ex) 66 | { 67 | foreach (var exception in ex.Flatten().InnerExceptions) 68 | { 69 | exitCode = exception switch 70 | { 71 | DropboxException dex => HandleDropboxException(dex), 72 | TaskCanceledException tex => HandleTimeoutError(tex), 73 | _ => HandleGenericError(ex), 74 | }; 75 | } 76 | } 77 | catch (Exception ex) 78 | { 79 | Console.WriteLine("An error occurred and your upload was not completed."); 80 | Console.WriteLine(ex); 81 | } 82 | 83 | return (int)exitCode; 84 | } 85 | 86 | static FileToUpload[] GetFiles(string source, UploadOptions options) 87 | { 88 | // Determine whether source is a file or directory 89 | var attr = File.GetAttributes(source); 90 | if (attr.HasFlag(FileAttributes.Directory)) 91 | { 92 | Output($"Uploading folder \"{source}\" to {(!string.IsNullOrEmpty(options.DropboxPath) ? options.DropboxPath : "Dropbox")}", options); 93 | Output("Ctrl-C to cancel", options); 94 | ShowCancelHelp = false; 95 | 96 | if(options.Recursive) 97 | { 98 | EnumerationOptions enumerationOptions = new() 99 | { 100 | RecurseSubdirectories = true 101 | }; 102 | 103 | // Because we're recursing subdirectories, we may need to convert relative paths into relative paths in the Dropbox destination 104 | return [.. Directory.GetFiles(source, "*", enumerationOptions).Select(x => new FileToUpload(x, source))]; 105 | } 106 | 107 | return [.. Directory.GetFiles(source).Select(x => new FileToUpload(x))]; 108 | } 109 | 110 | return [new FileToUpload(source)]; 111 | } 112 | 113 | private static async Task Upload(IEnumerable files, UploadOptions options, DropboxClient client, 114 | CancellationToken cancellationToken) 115 | { 116 | foreach (var file in files) 117 | { 118 | var destinationPath = string.IsNullOrWhiteSpace(file.Subfolder) ? options.DropboxPath : $"{options.DropboxPath}/{file.Subfolder}"; 119 | await Upload(file.FullPath, file.Name, destinationPath, options, client, cancellationToken); 120 | if(cancellationToken.IsCancellationRequested) 121 | { 122 | break; 123 | } 124 | } 125 | } 126 | 127 | private static async Task Upload(string source, string filename, string destinationPath, UploadOptions options, DropboxClient client, 128 | CancellationToken cancellationToken) 129 | { 130 | Output($"Uploading {filename} to {destinationPath}", options); 131 | Console.Title = $"Uploading {filename} to {destinationPath}"; 132 | 133 | if (ShowCancelHelp) 134 | { 135 | Output("Ctrl-C to cancel", options); 136 | ShowCancelHelp = false; 137 | } 138 | 139 | using var fs = new FileStream(source, FileMode.Open, FileAccess.Read); 140 | Metadata uploaded; 141 | 142 | var useChunked = options.Chunked; 143 | 144 | if (!useChunked && fs.Length >= DropboxClientExtensions.ChunkedThreshold) 145 | { 146 | Output("File is larger than 150MB, using chunked uploading for this file.", options); 147 | useChunked = true; 148 | } 149 | 150 | if (useChunked && fs.Length <= options.ChunkSize) 151 | { 152 | Output("File is smaller than the specified chunk size, disabling chunked uploading for this file.", options); 153 | useChunked = false; 154 | } 155 | 156 | if (useChunked) 157 | { 158 | var progress = ConfigureProgressHandler(options, fs.Length); 159 | uploaded = await client.UploadChunked(destinationPath, filename, fs, progress, options.ChunkSize, cancellationToken); 160 | } 161 | else 162 | { 163 | uploaded = await client.Upload(destinationPath, filename, fs); 164 | } 165 | 166 | Output("Whoosh...", options); 167 | Output($"Uploaded {uploaded.Name} to {uploaded.PathDisplay}; Revision {uploaded.AsFile.Rev}", options); 168 | } 169 | 170 | private static void Output(string message, UploadOptions options) 171 | { 172 | if (options.Quiet) 173 | { 174 | return; 175 | } 176 | 177 | Console.WriteLine(message); 178 | } 179 | 180 | private static IProgress ConfigureProgressHandler(UploadOptions options, long fileSize) 181 | { 182 | if (options.NoProgress || options.Quiet) 183 | { 184 | return new NoProgressDisplay(fileSize, options.Quiet); 185 | } 186 | 187 | if (options.Bytes) 188 | { 189 | return new BytesProgressDisplay(fileSize); 190 | } 191 | 192 | return new PercentProgressDisplay(fileSize); 193 | } 194 | 195 | private static ExitCode HandleDropboxException(DropboxException ex) 196 | { 197 | Console.WriteLine("An error occurred and your file was not uploaded."); 198 | 199 | (ExitCode exitCode, string message) = ex switch 200 | { 201 | AuthException authException => (ExitCode.AccessDenied, $"An authentication error occurred: {authException}"), 202 | AccessException accessException => (ExitCode.AccessDenied, $"An access error occurred: {accessException}"), 203 | RateLimitException rateLimitException => (ExitCode.Canceled, $"A rate limit error occurred: {rateLimitException}"), 204 | BadInputException badInputException => (ExitCode.BadArguments, $"An error occurred: {badInputException}"), 205 | HttpException httpException => (ExitCode.BadArguments, $"An HTTP error occurred: {httpException}"), 206 | _ => (ExitCode.UnknownError, ex.Message) 207 | }; 208 | 209 | Console.WriteLine(message); 210 | 211 | return exitCode; 212 | } 213 | 214 | private static ExitCode HandleGenericError(Exception ex) 215 | { 216 | Console.WriteLine("An error occurred and your file was not uploaded."); 217 | Console.WriteLine(ex); 218 | 219 | return ExitCode.UnknownError; 220 | } 221 | 222 | private static ExitCode HandleTimeoutError(TaskCanceledException ex) 223 | { 224 | Console.WriteLine("An HTTP operation timed out and your file was not uploaded."); 225 | Console.WriteLine(ex); 226 | 227 | return ExitCode.Canceled; 228 | } 229 | 230 | private static Task BadArgs(IEnumerable errors) 231 | { 232 | return Task.FromResult((int)ExitCode.BadArguments); 233 | } 234 | } 235 | } -------------------------------------------------------------------------------- /.nuget/NuGet.targets: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | $(MSBuildProjectDirectory)\..\ 5 | 6 | 7 | false 8 | 9 | 10 | false 11 | 12 | 13 | true 14 | 15 | 16 | false 17 | 18 | 19 | 20 | 21 | 22 | 26 | 27 | 28 | 29 | 30 | $([System.IO.Path]::Combine($(SolutionDir), ".nuget")) 31 | 32 | 33 | 34 | 35 | $(SolutionDir).nuget 36 | 37 | 38 | 39 | $(MSBuildProjectDirectory)\packages.$(MSBuildProjectName.Replace(' ', '_')).config 40 | $(MSBuildProjectDirectory)\packages.$(MSBuildProjectName).config 41 | 42 | 43 | 44 | $(MSBuildProjectDirectory)\packages.config 45 | $(PackagesProjectConfig) 46 | 47 | 48 | 49 | 50 | $(NuGetToolsPath)\NuGet.exe 51 | @(PackageSource) 52 | 53 | "$(NuGetExePath)" 54 | mono --runtime=v4.0.30319 "$(NuGetExePath)" 55 | 56 | $(TargetDir.Trim('\\')) 57 | 58 | -RequireConsent 59 | -NonInteractive 60 | 61 | "$(SolutionDir) " 62 | "$(SolutionDir)" 63 | 64 | 65 | $(NuGetCommand) install "$(PackagesConfig)" -source "$(PackageSources)" $(NonInteractiveSwitch) $(RequireConsentSwitch) -solutionDir $(PaddedSolutionDir) 66 | $(NuGetCommand) pack "$(ProjectPath)" -Properties "Configuration=$(Configuration);Platform=$(Platform)" $(NonInteractiveSwitch) -OutputDirectory "$(PackageOutputDir)" -symbols 67 | 68 | 69 | 70 | RestorePackages; 71 | $(BuildDependsOn); 72 | 73 | 74 | 75 | 76 | $(BuildDependsOn); 77 | BuildPackage; 78 | 79 | 80 | 81 | 82 | 83 | 84 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 99 | 100 | 103 | 104 | 105 | 106 | 108 | 109 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 141 | 142 | 143 | 144 | 145 | --------------------------------------------------------------------------------