├── 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 | 
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 |
--------------------------------------------------------------------------------