diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 24703f3..0c1809e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -41,7 +41,7 @@ jobs: --configuration Release ` --runtime win-x64 ` --self-contained true ` - --output SFTPSync/bin/Release/net8.0-windows/publish/win-x64/ ` + --output SFTPSync/bin/Release/net8.0-windows ` /p:PublishSingleFile=true ` /p:PublishReadyToRun=true @@ -51,7 +51,7 @@ jobs: --configuration Release ` --runtime win-x64 ` --self-contained true ` - --output SFTPSyncStop/bin/Release/net8.0-windows/publish/win-x64/ ` + --output SFTPSyncStop/bin/Release/net8.0-windows ` /p:PublishSingleFile=true ` /p:PublishReadyToRun=true @@ -61,7 +61,7 @@ jobs: --configuration Release ` --runtime win-x64 ` --self-contained true ` - --output SFTPSyncUI/bin/Release/net8.0-windows/publish/win-x64/ ` + --output SFTPSyncUI/bin/Release/net8.0-windows ` /p:PublishSingleFile=true ` /p:PublishReadyToRun=true @@ -75,26 +75,52 @@ jobs: AZURE_CERT_NAME: ${{ secrets.AZURE_CERT_NAME }} shell: pwsh run: | - $publishDirs = @("SFTPSync/bin/Release/net8.0-windows/publish/win-x64", "SFTPSyncStop/bin/Release/net8.0-windows/publish/win-x64", "SFTPSyncUI/bin/Release/net8.0-windows/publish/win-x64") - foreach ($dir in $publishDirs) { - if (Test-Path $dir) { - $files = Get-ChildItem -Path $dir -Include *.exe, *.dll -Recurse - foreach ($file in $files) { - Write-Host "Signing $($file.FullName)" - AzureSignTool sign ` - -kvu $env:AZURE_KEY_VAULT_URL ` - -kvi $env:AZURE_APPLICATION_ID ` - -kvs $env:AZURE_CLIENT_SECRET ` - -kvt $env:AZURE_TENANT_ID ` - -kvc $env:AZURE_CERT_NAME ` - -tr http://timestamp.digicert.com ` - -fd sha256 ` - -td sha256 ` - $file.FullName - } - } else { - Write-Host "Directory $dir not found" + $solutionPath = "SFTPSync.sln" + $projectPaths = @() + $projectMatches = Select-String -Path $solutionPath -Pattern 'Project\(\".*\"\)\s=\s\".*\",\s\"(?[^\"]+\.csproj)\"' + foreach ($match in $projectMatches) { + $projectPaths += $match.Matches[0].Groups["path"].Value + } + + $targets = @() + foreach ($projectPath in $projectPaths) { + if (!(Test-Path $projectPath)) { + Write-Host "Project not found: $projectPath" + continue + } + + [xml]$projXml = Get-Content $projectPath + $assemblyName = ($projXml.Project.PropertyGroup | Where-Object { $_.AssemblyName } | Select-Object -First 1).AssemblyName + if ([string]::IsNullOrWhiteSpace($assemblyName)) { + $assemblyName = [System.IO.Path]::GetFileNameWithoutExtension($projectPath) + } + + $outputType = ($projXml.Project.PropertyGroup | Where-Object { $_.OutputType } | Select-Object -First 1).OutputType + $extension = if ($outputType -in @("Exe", "WinExe")) { ".exe" } else { ".dll" } + + $projectDir = Split-Path $projectPath -Parent + $releaseDir = Join-Path $projectDir "bin/Release" + if (!(Test-Path $releaseDir)) { + Write-Host "Release output not found: $releaseDir" + continue } + + $targets += Get-ChildItem -Path $releaseDir -Recurse -Filter "$assemblyName$extension" + } + + $targets = $targets | Sort-Object -Property FullName -Unique + foreach ($file in $targets) { + Write-Host "Signing $($file.FullName)" + AzureSignTool sign ` + -kvu $env:AZURE_KEY_VAULT_URL ` + -kvi $env:AZURE_APPLICATION_ID ` + -kvs $env:AZURE_CLIENT_SECRET ` + -kvt $env:AZURE_TENANT_ID ` + -kvc $env:AZURE_CERT_NAME ` + -tr http://timestamp.digicert.com ` + -fd sha256 ` + -td sha256 ` + $file.FullName } - name: Add WiX Toolset to PATH diff --git a/SFTPSyncLib/RemoteSync.cs b/SFTPSyncLib/RemoteSync.cs index 715b56f..71e015b 100644 --- a/SFTPSyncLib/RemoteSync.cs +++ b/SFTPSyncLib/RemoteSync.cs @@ -1,5 +1,6 @@ using Renci.SshNet; using Renci.SshNet.Sftp; +using System.Collections.Concurrent; namespace SFTPSyncLib { @@ -16,6 +17,8 @@ public class RemoteSync : IDisposable SftpClient _sftp; SyncDirector _director; HashSet _activeDirSync = new HashSet(); + readonly SemaphoreSlim _sftpLock = new SemaphoreSlim(1, 1); + bool _disposed; public Task DoneMakingFolders { get; } @@ -51,6 +54,94 @@ public RemoteSync(string host, string username, string password, }); } + public RemoteSync(string host, string username, string password, + string localRootDirectory, string remoteRootDirectory, + string searchPattern, SyncDirector director, List? excludedFolders, Task initialSyncTask) + { + _host = host; + _username = username; + _password = password; + _searchPattern = searchPattern; + _localRootDirectory = localRootDirectory; + _remoteRootDirectory = remoteRootDirectory; + _director = director; + _excludedFolders = excludedFolders ?? new List(); + _sftp = new SftpClient(host, username, password); + _sftp.Connect(); + + DoneMakingFolders = Task.CompletedTask; + DoneInitialSync = initialSyncTask; + + DoneInitialSync.ContinueWith((tmp) => + { + _director.AddCallback(searchPattern, (args) => Fsw_Changed(null, args)); + }); + } + + public static async Task RunSharedInitialSyncAsync( + string host, + string username, + string password, + string localRootDirectory, + string remoteRootDirectory, + string[] searchPatterns, + List? excludedFolders, + int workerCount) + { + if (workerCount <= 0 || searchPatterns.Length == 0) + { + return; + } + + try + { + using (var sftp = new SftpClient(host, username, password)) + { + sftp.Connect(); + await CreateDirectoriesInternal(sftp, localRootDirectory, localRootDirectory, remoteRootDirectory, excludedFolders); + } + } + catch (Exception ex) + { + Logger.LogError($"Failed to create directories. Exception: {ex.Message}"); + throw; + } + + var workQueue = new ConcurrentQueue(); + foreach (var pair in EnumerateLocalDirectories(localRootDirectory, remoteRootDirectory, excludedFolders)) + { + foreach (var pattern in searchPatterns) + { + workQueue.Enqueue(new SyncWorkItem(pair.LocalPath, pair.RemotePath, pattern)); + } + } + + var workers = new List(); + for (int i = 0; i < workerCount; i++) + { + workers.Add(Task.Run(async () => + { + using (var sftp = new SftpClient(host, username, password)) + { + sftp.Connect(); + while (workQueue.TryDequeue(out var item)) + { + try + { + await SyncDirectoryAsync(sftp, item.LocalPath, item.RemotePath, item.SearchPattern); + } + catch (Exception ex) + { + Logger.LogError($"Failed to sync {item.LocalPath} ({item.SearchPattern}). Exception: {ex.Message}"); + } + } + } + })); + } + + await Task.WhenAll(workers); + } + /// /// Sync changes for a file. This is only used for changes AFTER the initial sync has completed. /// @@ -85,7 +176,7 @@ public static void SyncFile(SftpClient sftp, string sourcePath, string destinati return; } - catch (Exception ex) when (ex is IOException || ex is FileNotFoundException) + catch (Exception ex) { retryCount++; if (retryCount >= maxRetries) @@ -118,30 +209,7 @@ public static Task> SyncDirectoryAsync(SftpClient sftp, st private string[] FilteredDirectories(string localPath) { - return Directory.GetDirectories(localPath).Where(path => - { - var relativePath = path.Substring(_localRootDirectory.Length); - - // Existing exclusions - bool isExcluded = relativePath.EndsWith(".git") - || relativePath.EndsWith(".vs") - || relativePath.EndsWith("bin") - || relativePath.EndsWith("obj") - || relativePath.Contains("."); - - // Check _excludedFolders - if (!isExcluded && _excludedFolders != null && _excludedFolders.Count > 0) - { - string fullPath = Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar); - isExcluded = _excludedFolders.Any(excluded => - { - var excludedFullPath = Path.GetFullPath(excluded).TrimEnd(Path.DirectorySeparatorChar); - return string.Equals(fullPath, excludedFullPath, StringComparison.OrdinalIgnoreCase); - }); - } - - return !isExcluded; - }).ToArray(); + return FilteredDirectories(_localRootDirectory, localPath, _excludedFolders); } public async Task CreateDirectories(string localPath, string remotePath) @@ -150,30 +218,10 @@ public async Task CreateDirectories(string localPath, string remotePath) try { - //Got local directories to sync - var localDirectories = FilteredDirectories(localPath); - - //Get remote directories - var remoteDirectories = (await ListDirectoryAsync(_sftp, remotePath)).Where(item => item.IsDirectory).ToDictionary(item => - { - if (item.Name.Contains(".DIR", StringComparison.OrdinalIgnoreCase)) - return item.Name.Remove(item.Name.IndexOf(".DIR", StringComparison.OrdinalIgnoreCase)); - else - return item.Name; - }); + if (!EnsureConnectedSafe()) + return; - //Compare local and remote directories, creating missing ones, and recurse for subdirectories - foreach (var item in localDirectories) - { - var directoryName = item.Split(Path.DirectorySeparatorChar).Last(); - if (!remoteDirectories.ContainsKey(directoryName)) - { - //Create new remote directory - _sftp.CreateDirectory(remotePath + "/" + directoryName); - } - //And recurse for any subdirectories it may need - await CreateDirectories(localPath + "\\" + directoryName, remotePath + "/" + directoryName); - } + await CreateDirectoriesInternal(_sftp, _localRootDirectory, localPath, remotePath, _excludedFolders); } catch (Exception) { @@ -194,6 +242,9 @@ public async Task InitialSync(string localPath, string remotePath) //Wait for the folders to be created before starting the initial sync await DoneMakingFolders; + if (!EnsureConnectedSafe()) + return; + //Get the local directories to sync var localDirectories = FilteredDirectories(localPath); @@ -216,6 +267,82 @@ public async Task InitialSync(string localPath, string remotePath) await SyncDirectoryAsync(_sftp, localPath, remotePath, _searchPattern); } + private static string[] FilteredDirectories(string localRootDirectory, string localPath, List? excludedFolders) + { + return Directory.GetDirectories(localPath).Where(path => + { + var relativePath = path.Substring(localRootDirectory.Length); + + bool isExcluded = relativePath.EndsWith(".git") + || relativePath.EndsWith(".vs") + || relativePath.EndsWith("bin") + || relativePath.EndsWith("obj") + || relativePath.Contains("."); + + if (!isExcluded && excludedFolders != null && excludedFolders.Count > 0) + { + string fullPath = Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar); + isExcluded = excludedFolders.Any(excluded => + { + var excludedFullPath = Path.GetFullPath(excluded).TrimEnd(Path.DirectorySeparatorChar); + return string.Equals(fullPath, excludedFullPath, StringComparison.OrdinalIgnoreCase); + }); + } + + return !isExcluded; + }).ToArray(); + } + + private static IEnumerable<(string LocalPath, string RemotePath)> EnumerateLocalDirectories( + string localRootDirectory, + string remoteRootDirectory, + List? excludedFolders) + { + var stack = new Stack<(string LocalPath, string RemotePath)>(); + stack.Push((localRootDirectory, remoteRootDirectory)); + + while (stack.Count > 0) + { + var current = stack.Pop(); + yield return current; + + foreach (var directory in FilteredDirectories(localRootDirectory, current.LocalPath, excludedFolders)) + { + var directoryName = directory.Split(Path.DirectorySeparatorChar).Last(); + var remotePath = current.RemotePath + "/" + directoryName; + stack.Push((directory, remotePath)); + } + } + } + + private static async Task CreateDirectoriesInternal( + SftpClient sftp, + string localRootDirectory, + string localPath, + string remotePath, + List? excludedFolders) + { + var localDirectories = FilteredDirectories(localRootDirectory, localPath, excludedFolders); + + var remoteDirectories = (await ListDirectoryAsync(sftp, remotePath)).Where(item => item.IsDirectory).ToDictionary(item => + { + if (item.Name.Contains(".DIR", StringComparison.OrdinalIgnoreCase)) + return item.Name.Remove(item.Name.IndexOf(".DIR", StringComparison.OrdinalIgnoreCase)); + else + return item.Name; + }); + + foreach (var item in localDirectories) + { + var directoryName = item.Split(Path.DirectorySeparatorChar).Last(); + if (!remoteDirectories.ContainsKey(directoryName)) + { + sftp.CreateDirectory(remotePath + "/" + directoryName); + } + await CreateDirectoriesInternal(sftp, localRootDirectory, localPath + "\\" + directoryName, remotePath + "/" + directoryName, excludedFolders); + } + } + public static Task UploadFileAsync(SftpClient sftp, Stream file, string destination) { @@ -256,67 +383,167 @@ public static bool IsFileReady(String sFilename) } } + private void EnsureConnected() + { + if (_disposed) + throw new ObjectDisposedException(nameof(RemoteSync)); - private async void Fsw_Changed(object? sender, FileSystemEventArgs arg) + if (_sftp.IsConnected) + return; + + _sftp.Connect(); + } + + private bool EnsureConnectedSafe() { - if (arg.ChangeType == WatcherChangeTypes.Changed || arg.ChangeType == WatcherChangeTypes.Created - || arg.ChangeType == WatcherChangeTypes.Renamed) + try { - var changedPath = Path.GetDirectoryName(arg.FullPath); - var relativePath = _localRootDirectory == changedPath ? "" : changedPath?.Substring(_localRootDirectory.Length).Replace('\\', '/'); - var fullRemotePath = _remoteRootDirectory + relativePath; - await Task.Yield(); - bool makeDirectory = true; - lock (_activeDirSync) - { - if (changedPath == null) - return; - if (_activeDirSync.Contains(changedPath)) - makeDirectory = false; - else - _activeDirSync.Add(changedPath); - } + EnsureConnected(); + return true; + } + catch (Exception ex) + { + Logger.LogError($"SFTP connection error: {ex.Message}"); + return false; + } + } - //check if we're a new directory - if (makeDirectory && Directory.Exists(arg.FullPath) && !_sftp.Exists(arg.FullPath)) - { - _sftp.CreateDirectory(fullRemotePath); - } - if (makeDirectory) + private async void Fsw_Changed(object? sender, FileSystemEventArgs arg) + { + try + { + if (arg.ChangeType == WatcherChangeTypes.Changed || arg.ChangeType == WatcherChangeTypes.Created + || arg.ChangeType == WatcherChangeTypes.Renamed) { + var changedPath = Path.GetDirectoryName(arg.FullPath); + var relativePath = _localRootDirectory == changedPath ? "" : changedPath?.Substring(_localRootDirectory.Length).Replace('\\', '/'); + var fullRemotePath = _remoteRootDirectory + relativePath; + await Task.Yield(); + bool makeDirectory = true; lock (_activeDirSync) { - _activeDirSync.Remove(changedPath); + if (changedPath == null) + return; + if (_activeDirSync.Contains(changedPath)) + makeDirectory = false; + else + _activeDirSync.Add(changedPath); + } + + bool connectionOk; + await _sftpLock.WaitAsync(); + try + { + connectionOk = EnsureConnectedSafe(); + if (connectionOk) + { + //check if we're a new directory + if (makeDirectory && Directory.Exists(arg.FullPath) && !_sftp.Exists(fullRemotePath)) + { + _sftp.CreateDirectory(fullRemotePath); + } + } + } + finally + { + _sftpLock.Release(); } - } - while (!IsFileReady(arg.FullPath)) - await Task.Delay(25); + if (!connectionOk) + { + if (makeDirectory) + { + lock (_activeDirSync) + { + _activeDirSync.Remove(changedPath); + } + } + return; + } + if (makeDirectory) + { + lock (_activeDirSync) + { + _activeDirSync.Remove(changedPath); + } + } - lock (_activeDirSync) - { - if (_activeDirSync.Contains(arg.FullPath)) + if (Directory.Exists(arg.FullPath)) return; - else - _activeDirSync.Add(arg.FullPath); - } - SyncFile(_sftp, arg.FullPath, fullRemotePath + "/" + Path.GetFileName(arg.FullPath) ); - lock (_activeDirSync) - { - _activeDirSync.Remove(arg.FullPath); + var waitStart = DateTime.UtcNow; + while (!IsFileReady(arg.FullPath)) + { + if (!File.Exists(arg.FullPath)) + return; + if (DateTime.UtcNow - waitStart > TimeSpan.FromSeconds(30)) + { + Logger.LogWarnig($"Timed out waiting for file to be ready: {arg.FullPath}"); + return; + } + await Task.Delay(25); + } + + lock (_activeDirSync) + { + if (_activeDirSync.Contains(arg.FullPath)) + return; + else + _activeDirSync.Add(arg.FullPath); + } + + bool fileConnectionOk; + await _sftpLock.WaitAsync(); + try + { + fileConnectionOk = EnsureConnectedSafe(); + if (fileConnectionOk) + { + SyncFile(_sftp, arg.FullPath, fullRemotePath + "/" + Path.GetFileName(arg.FullPath)); + } + } + finally + { + _sftpLock.Release(); + lock (_activeDirSync) + { + _activeDirSync.Remove(arg.FullPath); + } + } + + if (!fileConnectionOk) + return; } } + catch (Exception ex) + { + Logger.LogError($"Unhandled exception in file sync handler: {ex.Message}"); + } } public void Dispose() { if (_sftp != null) { + _disposed = true; _sftp.Dispose(); } } + + private sealed class SyncWorkItem + { + public SyncWorkItem(string localPath, string remotePath, string searchPattern) + { + LocalPath = localPath; + RemotePath = remotePath; + SearchPattern = searchPattern; + } + + public string LocalPath { get; } + public string RemotePath { get; } + public string SearchPattern { get; } + } } } diff --git a/SFTPSyncLib/SyncDirector.cs b/SFTPSyncLib/SyncDirector.cs index 4ccdff0..4d3a25f 100644 --- a/SFTPSyncLib/SyncDirector.cs +++ b/SFTPSyncLib/SyncDirector.cs @@ -13,12 +13,15 @@ public SyncDirector(string rootFolder) _fsw = new FileSystemWatcher(rootFolder, "*.*") { IncludeSubdirectories = true, - NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName + NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName, + // Max allowed size; helps reduce missed events during bursts. + InternalBufferSize = 64 * 1024 }; _fsw.Changed += Fsw_Changed; _fsw.Created += Fsw_Created; _fsw.Renamed += Fsw_Renamed; + _fsw.Error += Fsw_Error; _fsw.EnableRaisingEvents = true; } @@ -61,5 +64,10 @@ private void Fsw_Changed(object sender, FileSystemEventArgs e) } } } + + private void Fsw_Error(object sender, ErrorEventArgs e) + { + Logger.LogError($"FileSystemWatcher error: {e.GetException()?.Message}"); + } } } diff --git a/SFTPSyncSetup/Product.wxs b/SFTPSyncSetup/Product.wxs index abf3c0f..927e67c 100644 --- a/SFTPSyncSetup/Product.wxs +++ b/SFTPSyncSetup/Product.wxs @@ -5,7 +5,7 @@ Id="*" Name="SFTPSync" Language="1033" - Version="1.4" + Version="1.5" Manufacturer="Synergex International Corporation" UpgradeCode="6000f870-b811-4e22-b80b-5b8956317d09"> @@ -53,71 +53,71 @@ - + - + - - - + - - - + - - - + - + - + - + - + - + - + - - - + - - - + - + - + - + @@ -175,17 +175,17 @@ - + - - + diff --git a/SFTPSyncSetup/SFTPSyncSetup.wixproj b/SFTPSyncSetup/SFTPSyncSetup.wixproj index 05c1909..33fabd6 100644 --- a/SFTPSyncSetup/SFTPSyncSetup.wixproj +++ b/SFTPSyncSetup/SFTPSyncSetup.wixproj @@ -6,7 +6,7 @@ 3.10 5074cbcb-641b-4a9c-b3bc-8dd0b78810a6 2.0 - SFTPSync-1.4 + SFTPSync-1.5 Package SFTPSyncSetup diff --git a/SFTPSyncUI/SFTPSyncUI.cs b/SFTPSyncUI/SFTPSyncUI.cs index f4d7577..296e91f 100644 --- a/SFTPSyncUI/SFTPSyncUI.cs +++ b/SFTPSyncUI/SFTPSyncUI.cs @@ -3,6 +3,7 @@ using SFTPSyncLib; using System.IO.Pipes; using System.Reflection; +using System.Linq; namespace SFTPSyncUI { @@ -27,6 +28,23 @@ static void Main() if (processPath != null) ExecutableFile = Path.ChangeExtension(processPath, ".exe"); + Application.ThreadException += (sender, args) => + { + Logger.LogError($"Unhandled UI exception: {args.Exception.Message}"); + }; + AppDomain.CurrentDomain.UnhandledException += (sender, args) => + { + if (args.ExceptionObject is Exception ex) + Logger.LogError($"Unhandled exception: {ex.Message}"); + else + Logger.LogError("Unhandled exception: unknown error"); + }; + TaskScheduler.UnobservedTaskException += (sender, args) => + { + Logger.LogError($"Unobserved task exception: {args.Exception.Message}"); + args.SetObserved(); + }; + // Check if another instance is already running and if so, tell it to show its window, then exit bool createdNew; Mutex mutex = new Mutex(true, $"Global\\SFTPSyncUI", out createdNew); @@ -194,27 +212,51 @@ public static async void StartSync(Action loggerAction) var director = new SyncDirector(settings.LocalPath); - foreach (var pattern in settings.LocalSearchPattern.Split(';', StringSplitOptions.RemoveEmptyEntries)) + var patterns = settings.LocalSearchPattern + .Split(';', StringSplitOptions.RemoveEmptyEntries) + .Select(pattern => pattern.Trim()) + .Where(pattern => pattern.Length > 0) + .ToArray(); + + if (patterns.Length == 0) + { + Logger.LogError("No valid search patterns were configured."); + return; + } + + Task initialSyncTask; + try + { + initialSyncTask = RemoteSync.RunSharedInitialSyncAsync( + settings.RemoteHost, + settings.RemoteUsername, + DPAPIEncryption.Decrypt(settings.RemotePassword), + settings.LocalPath, + settings.RemotePath, + patterns, + settings.ExcludedDirectories, + patterns.Length); + } + catch (Exception ex) + { + Logger.LogError($"Failed to start initial sync. Exception: {ex.Message}"); + return; + } + + foreach (var pattern in patterns) { - if (RemoteSyncWorkers.Count > 0) - { - //The first sync worker will create all of the remote folders before it starts, - //to sync files matching it's pattern so wait for it to finish before starting - //the remaining sync workers - await RemoteSyncWorkers[0].DoneMakingFolders; - } try { RemoteSyncWorkers.Add(new RemoteSync( - settings.RemoteHost, - settings.RemoteUsername, - DPAPIEncryption.Decrypt(settings.RemotePassword), - settings.LocalPath, - settings.RemotePath, - pattern, - RemoteSyncWorkers.Count == 0, // Only the first worker will create remote folders + settings.RemoteHost, + settings.RemoteUsername, + DPAPIEncryption.Decrypt(settings.RemotePassword), + settings.LocalPath, + settings.RemotePath, + pattern, director, - settings.ExcludedDirectories)); + settings.ExcludedDirectories, + initialSyncTask)); Logger.LogInfo($"Started sync worker {RemoteSyncWorkers.Count} for pattern {pattern}"); } @@ -225,7 +267,16 @@ public static async void StartSync(Action loggerAction) } //Wait for all sync workers to finish initial sync then tell the user - await Task.WhenAll(RemoteSyncWorkers.Select(rsw => rsw.DoneInitialSync)); + try + { + await initialSyncTask; + } + catch (Exception ex) + { + Logger.LogError($"Initial sync failed. Exception: {ex.Message}"); + mainForm?.SetStatusBarText("Initial sync failed"); + return; + } Logger.LogInfo("Initial sync complete, real-time sync active"); mainForm?.SetStatusBarText("Real time sync active"); @@ -257,4 +308,4 @@ public static void StopSync(Action loggerAction) mainForm?.SetStatusBarText("Sync stopped"); } } -} \ No newline at end of file +} diff --git a/SFTPSyncUI/SFTPSyncUI.csproj b/SFTPSyncUI/SFTPSyncUI.csproj index 9e93ba4..a864dd6 100644 --- a/SFTPSyncUI/SFTPSyncUI.csproj +++ b/SFTPSyncUI/SFTPSyncUI.csproj @@ -20,10 +20,10 @@ LICENSE.md True Synergex International, Inc. - 1.4 - 1.4 + 1.5 + 1.5 preview - 1.4 + 1.5