Merge pull request #6669 from duplicati/feature/add-password-input-dialog

Add password dialog
This commit is contained in:
Kenneth Skovhede
2025-12-05 15:25:35 +01:00
committed by GitHub
13 changed files with 716 additions and 371 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

@@ -34,7 +34,9 @@ using Avalonia.Logging;
using Avalonia.Media.Imaging;
using Avalonia.Platform;
using Avalonia.Themes.Fluent;
using Avalonia.Styling;
using Avalonia.Threading;
using CoCoL;
using Duplicati.Library.AutoUpdater;
using Duplicati.Library.Logging;
@@ -178,6 +180,10 @@ namespace Duplicati.GUI.TrayIcon
if (tcs.Task.IsCompletedSuccessfully)
{
// Check if we need to show the password prompt
if (Program.NeedsPasswordPrompt)
ShowPasswordPromptAsync(lifetime).FireAndForget();
actionDelayer.SignalStart();
}
else
@@ -194,6 +200,42 @@ namespace Duplicati.GUI.TrayIcon
}
}
private async Task ShowPasswordPromptAsync(IClassicDesktopStyleApplicationLifetime lifetime)
{
try
{
var password = await PasswordPrompt.ShowPasswordDialogAsync(isChangePassword: false).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(password))
{
RunOnUIThreadInternal(() =>
{
try
{
lifetime.Shutdown();
}
catch { }
});
return;
}
// Password received, notify the callback
Program.Connection.UpdatePassword(password);
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "PasswordPromptFailed", ex, "Failed to show password prompt");
RunOnUIThreadInternal(() =>
{
try
{
lifetime.Shutdown();
}
catch { }
});
}
}
protected override IMenuItem CreateMenuItem(string text, MenuIcons icon, Action callback, IList<IMenuItem> subitems)
{
return new AvaloniaMenuItem(this, text, icon, callback, subitems);
@@ -348,6 +390,8 @@ namespace Duplicati.GUI.TrayIcon
MenuIcons.Quit => AvaloniaRunner.LoadBitmap("context-menu-quit.png"),
MenuIcons.Pause => AvaloniaRunner.LoadBitmap("context-menu-pause.png"),
MenuIcons.Resume => AvaloniaRunner.LoadBitmap("context-menu-resume.png"),
MenuIcons.Reconnect => AvaloniaRunner.LoadBitmap("context-menu-reconnect.png"),
MenuIcons.ChangePassword => AvaloniaRunner.LoadBitmap("context-menu-change-password.png"),
_ => null
};
}
@@ -490,6 +534,8 @@ namespace Duplicati.GUI.TrayIcon
public override void OnFrameworkInitializationCompleted()
{
RequestedThemeVariant = ThemeVariant.Default;
Styles.Add(new FluentTheme());
var icon = AvaloniaRunner.LoadIcon("normal.png");
@@ -87,8 +87,9 @@ namespace Duplicati.GUI.TrayIcon
private record BackgroundRequest(string Method, string Endpoint, string? Body, TimeSpan? Timeout = null);
private readonly string m_apiUri;
private readonly string m_baseUri;
private Uri m_serverUri;
private string m_apiUri;
private string m_baseUri;
private string m_password;
private string? m_accesstoken;
private bool m_isTryingWithPassword;
@@ -114,19 +115,45 @@ namespace Duplicati.GUI.TrayIcon
private Task m_pollThread;
private readonly CancellationTokenSource m_stopToken = new CancellationTokenSource();
private readonly Dictionary<string, string> m_options;
private readonly Program.PasswordSource m_passwordSource;
private readonly TaskCompletionSource<string> m_passwordUpdatedTcs = new TaskCompletionSource<string>();
public IServerStatus Status
{
get { return m_status; }
}
public void UpdatePassword(string newpassword)
{
m_password = newpassword;
m_accesstoken = null;
m_passwordUpdatedTcs.TrySetResult(newpassword);
}
public void UpdateServerUri(Uri newServer)
{
if (newServer == null)
throw new ArgumentNullException(nameof(newServer));
m_serverUri = newServer;
m_baseUri = Util.AppendDirSeparator(m_serverUri.ToString(), "/");
m_apiUri = m_baseUri + "api/v1";
// Reset state to ensure clean reconnection to the new server
m_lastEventId = 0;
m_lastDataUpdateId = -1;
m_lastNotificationId = -1;
}
private readonly IChannel<BackgroundRequest> m_workQueue =
Channel.Create<BackgroundRequest>(name: "TrayIconRequestQueue");
private readonly CancellationToken _applicationExitEvent;
public Program.PasswordSource PasswordSource => m_passwordSource;
public Uri ServerUri => m_serverUri;
public HttpServerConnection(IApplicationSettings? applicationSettings, System.Uri server, string password,
Program.PasswordSource passwordSource, bool disableTrayIconLogin, string acceptedHostCertificate,
Dictionary<string, string> options)
@@ -134,7 +161,8 @@ namespace Duplicati.GUI.TrayIcon
_applicationExitEvent = applicationSettings?.ApplicationExit
?? CancellationToken.None;
m_baseUri = Util.AppendDirSeparator(server.ToString(), "/");
m_serverUri = server;
m_baseUri = Util.AppendDirSeparator(m_serverUri.ToString(), "/");
m_apiUri = m_baseUri + "api/v1";
@@ -143,7 +171,6 @@ namespace Duplicati.GUI.TrayIcon
m_firstNotificationTime = DateTime.Now;
m_password = password;
m_options = options;
m_passwordSource = passwordSource;
var acceptedCertificates = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
@@ -208,8 +235,11 @@ namespace Duplicati.GUI.TrayIcon
});
}
public Task UpdateStatus()
=> UpdateStatusAsync(false);
public async Task UpdateStatus()
{
await PasswordAvailableIfNeeded().ConfigureAwait(false);
await UpdateStatusAsync(false).ConfigureAwait(false);
}
private async Task UpdateStatusAsync(bool longpoll)
{
@@ -258,6 +288,15 @@ namespace Duplicati.GUI.TrayIcon
m_disableTrayIconLogin = Library.Utility.Utility.ParseBool(str, false);
}
private async Task PasswordAvailableIfNeeded()
{
if (string.IsNullOrWhiteSpace(m_password) && m_passwordSource == Program.PasswordSource.SuppliedPassword)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(m_stopToken.Token, _applicationExitEvent);
await m_passwordUpdatedTcs.Task.WaitAsync(cts.Token).ConfigureAwait(false);
}
}
private async Task LongPollRunner()
{
var started = DateTime.Now;
@@ -276,6 +315,8 @@ namespace Duplicati.GUI.TrayIcon
await Task.Delay(waitTime, cts.Token).ConfigureAwait(false);
}
await PasswordAvailableIfNeeded().ConfigureAwait(false);
started = DateTime.Now;
await UpdateStatusAsync(true).ConfigureAwait(false);
errorCount = 0;
@@ -0,0 +1,234 @@
// Copyright (C) 2025, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
#nullable enable
using System;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Threading;
namespace Duplicati.GUI.TrayIcon;
internal static class PasswordPrompt
{
public static bool IsShowingDialog { get; private set; } = false;
/// <summary>
/// Shows a password prompt dialog within an already-running Avalonia application.
/// Must be called from the UI thread or will dispatch to it.
/// </summary>
/// <param name="isChangePassword">Whether this is a change password prompt</param>
/// <returns>A task that completes with the password, or null if cancelled</returns>
public static Task<string?> ShowPasswordDialogAsync(bool isChangePassword)
{
if (IsShowingDialog)
throw new InvalidOperationException("A password prompt dialog is already showing.");
IsShowingDialog = true;
var tcs = new TaskCompletionSource<string?>();
if (Dispatcher.UIThread.CheckAccess())
{
ShowDialogInternal(tcs, isChangePassword);
}
else
{
Dispatcher.UIThread.Post(() => ShowDialogInternal(tcs, isChangePassword));
}
return tcs.Task.ContinueWith(t =>
{
IsShowingDialog = false;
return t.Result;
});
}
private static void ShowDialogInternal(TaskCompletionSource<string?> tcs, bool isChangePassword)
{
try
{
var window = new PasswordPromptWindow(isChangePassword);
window.PasswordSubmitted += (_, pwd) =>
{
tcs.TrySetResult(pwd);
window.Close();
};
window.Cancelled += (_, _) =>
{
tcs.TrySetResult(null);
window.Close();
};
window.Closed += (_, _) =>
{
// Ensure the task completes even if window is closed another way
tcs.TrySetResult(null);
};
window.Show();
}
catch (Exception ex)
{
Console.Error.WriteLine($"Failed to show password prompt window: {ex.Message}");
tcs.TrySetResult(null);
}
}
}
internal class PasswordPromptWindow : Window
{
private readonly TextBox passwordBox;
private readonly TextBox hostUrlBox;
public event EventHandler<string?>? PasswordSubmitted;
public event EventHandler? Cancelled;
public PasswordPromptWindow(bool isChangePassword)
{
Title = "Duplicati TrayIcon Connect";
Width = 420;
Height = 210;
CanResize = false;
WindowStartupLocation = WindowStartupLocation.CenterScreen;
var description = new TextBlock
{
Text = isChangePassword
? "Enter the new Duplicati server password:"
: "Enter the Duplicati server password to connect:",
Margin = new Thickness(0, 0, 0, 10),
TextWrapping = TextWrapping.Wrap
};
passwordBox = new TextBox
{
Watermark = "Server password",
PasswordChar = '•',
HorizontalAlignment = HorizontalAlignment.Stretch,
Margin = new Thickness(0, 0, 0, 10)
};
var hostUrlLabel = new TextBlock
{
Text = "Duplicati server URL:",
Margin = new Thickness(0, 5, 0, 0),
TextWrapping = TextWrapping.Wrap
};
hostUrlBox = new TextBox
{
Text = Program.Connection?.ServerUri?.ToString() ?? string.Empty,
Watermark = "http://localhost:8200",
HorizontalAlignment = HorizontalAlignment.Stretch,
Margin = new Thickness(0, 0, 0, 10)
};
var okButton = new Button
{
Content = "Connect",
IsDefault = true,
Width = 100
};
okButton.Click += (_, _) => SubmitPassword();
var cancelButton = new Button
{
Content = "Cancel",
IsCancel = true,
Width = 100
};
cancelButton.Click += (_, _) => Cancel();
var buttonPanel = new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Right,
Spacing = 10
};
buttonPanel.Children.Add(cancelButton);
buttonPanel.Children.Add(okButton);
var contentPanel = new Grid
{
RowDefinitions = new RowDefinitions("*,Auto")
};
var inputPanel = new StackPanel
{
Children =
{
description,
passwordBox,
hostUrlLabel,
hostUrlBox
}
};
Grid.SetRow(inputPanel, 0);
Grid.SetRow(buttonPanel, 1);
contentPanel.Children.Add(inputPanel);
contentPanel.Children.Add(buttonPanel);
Content = new Border
{
Padding = new Thickness(20),
Child = contentPanel
};
Opened += (_, _) => passwordBox.Focus();
}
private void SubmitPassword()
{
var password = passwordBox.Text;
var hostUrlText = hostUrlBox.Text?.Trim();
if (!string.IsNullOrWhiteSpace(hostUrlText) && Program.Connection != null)
{
try
{
var uri = new Uri(hostUrlText, UriKind.Absolute);
if (!uri.Equals(Program.Connection.ServerUri))
{
Program.Connection.UpdateServerUri(uri);
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"Invalid host URL '{hostUrlText}': {ex.Message}");
}
}
PasswordSubmitted?.Invoke(this, password);
}
private void Cancel()
{
Cancelled?.Invoke(this, EventArgs.Empty);
}
}
+358 -362
View File
@@ -1,362 +1,358 @@
// Copyright (C) 2025, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading;
using CoCoL;
using Duplicati.CommandLine;
using Duplicati.Library.AutoUpdater;
using Duplicati.Library.Interface;
using Duplicati.Library.Utility;
using Duplicati.Server;
using Duplicati.Server.Database;
using Duplicati.WebserverCore.Services;
using Microsoft.Extensions.DependencyInjection;
using Uri = System.Uri;
namespace Duplicati.GUI.TrayIcon
{
public static class Program
{
/// <summary>
/// The source of the password
/// </summary>
public enum PasswordSource
{
/// <summary>
/// No password, using token information from the database
/// </summary>
Database,
/// <summary>
/// No password supplied, using the hosted server
/// </summary>
HostedServer,
/// <summary>
/// The password was supplied on the commandline
/// </summary>
SuppliedPassword
}
public static HttpServerConnection Connection;
private const string HOSTURL_OPTION = "hosturl";
private const string NOHOSTEDSERVER_OPTION = "no-hosted-server";
private const string READCONFIGFROMDB_OPTION = "read-config-from-db";
private const string ACCEPTED_SSL_CERTIFICATE = "host-cert-hash";
private const string DETACHED_PROCESS = "detached-process";
private const string BROWSER_COMMAND_OPTION = "browser-command";
private static readonly IReadOnlySet<string> DETATCHED_WEBERVER_OPTIONS = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
WebServerLoader.OPTION_WEBSERVICE_PASSWORD,
WebServerLoader.OPTION_PORT,
DataFolderManager.SERVER_DATAFOLDER_OPTION,
DataFolderManager.PORTABLE_MODE_OPTION
};
private static string DEFAULT_HOSTURL => $"http://{Utility.IpVersionCompatibleLoopback}:8200";
private static string _browser_command = null;
private static bool disableTrayIconLogin = false;
private static bool openui = false;
private static Uri serverURL = new(DEFAULT_HOSTURL);
public static string BrowserCommand { get { return _browser_command; } }
public static Server.Database.Connection databaseConnection = null;
/// <summary>
/// The main entry point for the application.
/// </summary>
[STAThread]
public static int Main(string[] _args)
{
PreloadSettingsLoader.ConfigurePreloadSettings(ref _args, PackageHelper.NamedExecutable.TrayIcon);
var args = new List<string>(_args);
var options = CommandLineParser.ExtractOptions(args);
if (OperatingSystem.IsWindows() && !Utility.ParseBoolOption(options, DETACHED_PROCESS))
Win32.AttachConsole(Win32.ATTACH_PARENT_PROCESS);
if (HelpOptionExtensions.IsArgumentAnyHelpString(_args))
{
Console.WriteLine("Supported commandline arguments:");
Console.WriteLine();
foreach (ICommandLineArgument arg in AllSupportedCommands)
Console.WriteLine("--{0}: {1}", arg.Name, arg.LongDescription);
Console.WriteLine("Additionally, these server options are also supported:");
Console.WriteLine();
foreach (ICommandLineArgument arg in WebserverSupportedCommands)
Console.WriteLine("--{0}: {1}", arg.Name, arg.LongDescription);
return 0;
}
// Apply secret provider, if any, so password etc can be retrieved
Library.Main.SecretProviderHelper.ApplySecretProviderAsync([], [], options, TempFolder.SystemTempPath, null, CancellationToken.None)
.Await();
options.TryGetValue(BROWSER_COMMAND_OPTION, out _browser_command);
HostedInstanceKeeper hosted = null;
string password = null;
var passwordSource = PasswordSource.SuppliedPassword;
var acceptedHostCertificate = options.GetValueOrDefault(ACCEPTED_SSL_CERTIFICATE, null);
var detached = Utility.ParseBoolOption(options, NOHOSTEDSERVER_OPTION);
var supportedCommands = BasicSupportedCommands.AsEnumerable();
if (detached)
{
supportedCommands = DetachSupportedCommands
.Concat(supportedCommands);
}
else
{
supportedCommands = supportedCommands.Concat(WebserverSupportedCommands);
}
// Validate options, and log to console
using (var logger = new ConsoleOutput(Console.Out, options))
CommandLineArgumentValidator.ValidateArguments(supportedCommands, options, Server.Program.KnownDuplicateOptions, new HashSet<string>());
if (!detached)
{
try
{
// Tell the hosted server it was started by the TrayIcon
var applicationSettings = new ApplicationSettings
{
Origin = "Tray icon"
};
passwordSource = PasswordSource.HostedServer;
// Ignore TrayIcon specific settings
foreach (var c in BasicSupportedCommands.Select(x => x.Name))
Server.Program.ValidationIgnoredOptions.Add(c);
hosted = new HostedInstanceKeeper(applicationSettings, _args);
}
catch (Exception ex)
{
if (ex.InnerException != null && ex.InnerException is Server.SingleInstance.MultipleInstanceException)
{
Console.WriteLine(Server.Strings.Program.AnotherInstanceDetected);
return 1;
}
else
throw;
}
// We have a hosted server, if this is the first run,
// we should open the main page
var connection = Server.Program.DuplicatiWebserver.Provider.GetRequiredService<Connection>();
openui = connection.ApplicationSettings.IsFirstRun || connection.ApplicationSettings.ServerPortChanged;
var scheme = connection.ApplicationSettings.UseHTTPS ? "https" : "http";
serverURL = new UriBuilder(serverURL)
{
Port = Server.Program.DuplicatiWebserver.Port,
Scheme = scheme
}.Uri;
if (connection.ApplicationSettings.UseHTTPS && string.IsNullOrWhiteSpace(acceptedHostCertificate))
acceptedHostCertificate = connection.ApplicationSettings.ServerSSLCertificate?.FirstOrDefault(x => x.HasPrivateKey)?.GetCertHashString();
}
else if (Utility.ParseBoolOption(options, READCONFIGFROMDB_OPTION))
{
if (File.Exists(Path.Combine(DataFolderManager.GetDataFolder(DataFolderManager.AccessMode.ReadWritePermissionSet), DataFolderManager.SERVER_DATABASE_FILENAME)))
{
passwordSource = PasswordSource.Database;
databaseConnection = Server.Program.GetDatabaseConnection(new ApplicationSettings(), options, true, false);
if (databaseConnection != null)
{
disableTrayIconLogin = databaseConnection.ApplicationSettings.DisableTrayIconLogin;
if (databaseConnection.ApplicationSettings.UseHTTPS && string.IsNullOrWhiteSpace(acceptedHostCertificate))
acceptedHostCertificate = databaseConnection.ApplicationSettings.ServerSSLCertificate?.FirstOrDefault(x => x.HasPrivateKey)?.GetCertHashString();
var scheme = databaseConnection.ApplicationSettings.UseHTTPS ? "https" : "http";
serverURL = new UriBuilder(serverURL)
{
Port = databaseConnection.ApplicationSettings.LastWebserverPort == -1 ? serverURL.Port : databaseConnection.ApplicationSettings.LastWebserverPort,
Scheme = scheme
}.Uri;
}
}
}
// Legacy undocumented way to provide the password
if (options.TryGetValue("webserver-password", out var pwd))
password = pwd;
if (options.TryGetValue(WebServerLoader.OPTION_WEBSERVICE_PASSWORD, out pwd))
password = pwd;
// Let the user specify the port, if they are not providing a hosturl
if (!options.ContainsKey(HOSTURL_OPTION) && options.TryGetValue(WebServerLoader.OPTION_PORT, out var portString) && int.TryParse(portString, out var port))
serverURL = new UriBuilder(serverURL) { Port = port }.Uri;
if (options.TryGetValue(HOSTURL_OPTION, out var url))
serverURL = new Uri(url);
if (string.IsNullOrWhiteSpace(password) && passwordSource == PasswordSource.SuppliedPassword)
{
Console.WriteLine($@"
When running the TrayIcon without a hosted server, you must provide the server password via the option --{WebServerLoader.OPTION_WEBSERVICE_PASSWORD}=<password>.
If the TrayIcon instance has read access to the server database, you can also or use the option --{READCONFIGFROMDB_OPTION}, possibly with --server-datafolder=<path>.
No password provided, unable to connect to server, exiting");
return 1;
}
StartTray(_args, options, hosted, passwordSource, password, acceptedHostCertificate);
return 0;
}
private static void StartTray(string[] _args, Dictionary<string, string> options, HostedInstanceKeeper hosted, PasswordSource passwordSource, string password, string acceptedHostCertificate)
{
using (hosted)
{
var reSpawn = 0;
do
{
if (reSpawn > 0)
Thread.Sleep(1000);
try
{
using (Connection = new HttpServerConnection(hosted?.applicationSettings, serverURL, password, passwordSource, disableTrayIconLogin, acceptedHostCertificate, options))
{
// Make sure we have the latest status, but don't care if it fails
Connection.UpdateStatus().FireAndForget();
using (var tk = RunTrayIcon())
{
if (Server.Program.ApplicationInstance != null)
Server.Program.ApplicationInstance.SecondInstanceDetected +=
x => tk.ShowStatusWindow();
// TODO: If we change to hosted browser this should be a callback
if (openui)
{
Connection.GetStatusWindowURLAsync().ContinueWith(t =>
{
if (t.IsFaulted)
{
Console.WriteLine("Failed to get status window URL: " + t.Exception.Message);
tk.NotifyUser("Failed to get status window URL", t.Exception.Message, NotificationType.Error);
return;
}
tk.ShowUrlInWindow(t.Result);
var connection = Server.Program.DuplicatiWebserver.Provider.GetRequiredService<Connection>();
connection.ApplicationSettings.IsFirstRun = false;
connection.ApplicationSettings.ServerPortChanged = false;
});
}
// If the server shuts down, shut down the tray-icon as well
Action shutdownEvent = () =>
{
// Make sure we do not start again after
// a controlled exit
reSpawn = 100;
tk.InvokeExit();
};
Connection.ConnectionClosed = shutdownEvent;
if (hosted != null)
hosted.InstanceShutdown = shutdownEvent;
tk.Init(_args);
// If the tray-icon quits, stop the server
reSpawn = 100;
// Make sure that the server shutdown does not access the tray-icon,
// as it would be disposed by now
if (hosted != null)
hosted.InstanceShutdown = null;
Connection.ConnectionClosed = null;
}
}
}
catch (HttpRequestException ex)
{
System.Diagnostics.Trace.WriteLine("Request error: " + ex);
Console.WriteLine("Request error: " + ex);
reSpawn++;
}
catch (Exception ex)
{
System.Diagnostics.Trace.WriteLine("Unexpected error: " + ex);
Console.WriteLine("Unexpected error: " + ex);
return;
}
} while (reSpawn < 3);
}
}
private static TrayIconBase RunTrayIcon()
=> new AvaloniaRunner();
public static ICommandLineArgument[] DetachSupportedCommands =>
[
new CommandLineArgument(NOHOSTEDSERVER_OPTION, CommandLineArgument.ArgumentType.String, "Disables local server", "Set this option to not spawn a local service, use if the TrayIcon should connect to a running service"),
new CommandLineArgument(READCONFIGFROMDB_OPTION, CommandLineArgument.ArgumentType.String, "Read server connection info from DB", $"Set this option to read server connection info for running service from its database (only together with {NOHOSTEDSERVER_OPTION})"),
.. WebserverSupportedCommands.Where(x => DETATCHED_WEBERVER_OPTIONS.Contains(x.Name))
];
public static ICommandLineArgument[] BasicSupportedCommands =>
[
new CommandLineArgument(HOSTURL_OPTION, CommandLineArgument.ArgumentType.String, "Selects the url to connect to", "Supply the url that the TrayIcon will connect to and show status for", DEFAULT_HOSTURL),
new CommandLineArgument(BROWSER_COMMAND_OPTION, CommandLineArgument.ArgumentType.String, "Sets the browser command", "Set this option to override the default browser detection"),
new CommandLineArgument(ACCEPTED_SSL_CERTIFICATE, CommandLineArgument.ArgumentType.String, "Accepts a specific SSL certificate", "Set this option to accept a specific SSL certificate, the value should be the hash of the certificate in hexadecimal format. Use * to accept any certificate (dangerous)"),
.. WindowsSupportedCommands
];
private static ICommandLineArgument[] WindowsSupportedCommands =>
OperatingSystem.IsWindows()
? [new CommandLineArgument(DETACHED_PROCESS, CommandLineArgument.ArgumentType.String, "Runs the tray-icon detached", "This option runs the tray-icon in detached mode, meaning that the process will exit immediately and not send output to the console of the caller")]
: [];
public static ICommandLineArgument[] WebserverSupportedCommands
=> Server.Program.SupportedCommands;
public static ICommandLineArgument[] AllSupportedCommands =>
[
.. DetachSupportedCommands,
.. BasicSupportedCommands
];
}
}
// Copyright (C) 2025, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Threading;
using CoCoL;
using Duplicati.CommandLine;
using Duplicati.Library.AutoUpdater;
using Duplicati.Library.Interface;
using Duplicati.Library.Utility;
using Duplicati.Server;
using Duplicati.Server.Database;
using Duplicati.WebserverCore.Services;
using Microsoft.Extensions.DependencyInjection;
using Uri = System.Uri;
namespace Duplicati.GUI.TrayIcon
{
public static class Program
{
/// <summary>
/// The source of the password
/// </summary>
public enum PasswordSource
{
/// <summary>
/// No password, using token information from the database
/// </summary>
Database,
/// <summary>
/// No password supplied, using the hosted server
/// </summary>
HostedServer,
/// <summary>
/// The password was supplied on the commandline
/// </summary>
SuppliedPassword
}
public static HttpServerConnection Connection;
/// <summary>
/// Indicates that a password is needed and should be requested via GUI after Avalonia starts
/// </summary>
public static bool NeedsPasswordPrompt { get; private set; }
private const string HOSTURL_OPTION = "hosturl";
private const string NOHOSTEDSERVER_OPTION = "no-hosted-server";
private const string READCONFIGFROMDB_OPTION = "read-config-from-db";
private const string ACCEPTED_SSL_CERTIFICATE = "host-cert-hash";
private const string DETACHED_PROCESS = "detached-process";
private const string BROWSER_COMMAND_OPTION = "browser-command";
private static readonly IReadOnlySet<string> DETATCHED_WEBERVER_OPTIONS = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
WebServerLoader.OPTION_WEBSERVICE_PASSWORD,
WebServerLoader.OPTION_PORT,
DataFolderManager.SERVER_DATAFOLDER_OPTION,
DataFolderManager.PORTABLE_MODE_OPTION
};
private static string DEFAULT_HOSTURL => $"http://{Utility.IpVersionCompatibleLoopback}:8200";
private static string _browser_command = null;
private static bool disableTrayIconLogin = false;
private static bool openui = false;
private static Uri serverURL = new(DEFAULT_HOSTURL);
public static string BrowserCommand { get { return _browser_command; } }
public static Server.Database.Connection databaseConnection = null;
/// <summary>
/// The main entry point for the application.
/// </summary>
[STAThread]
public static int Main(string[] _args)
{
PreloadSettingsLoader.ConfigurePreloadSettings(ref _args, PackageHelper.NamedExecutable.TrayIcon);
var args = new List<string>(_args);
var options = CommandLineParser.ExtractOptions(args);
if (OperatingSystem.IsWindows() && !Utility.ParseBoolOption(options, DETACHED_PROCESS))
Win32.AttachConsole(Win32.ATTACH_PARENT_PROCESS);
if (HelpOptionExtensions.IsArgumentAnyHelpString(_args))
{
Console.WriteLine("Supported commandline arguments:");
Console.WriteLine();
foreach (ICommandLineArgument arg in AllSupportedCommands)
Console.WriteLine("--{0}: {1}", arg.Name, arg.LongDescription);
Console.WriteLine("Additionally, these server options are also supported:");
Console.WriteLine();
foreach (ICommandLineArgument arg in WebserverSupportedCommands)
Console.WriteLine("--{0}: {1}", arg.Name, arg.LongDescription);
return 0;
}
// Apply secret provider, if any, so password etc can be retrieved
Library.Main.SecretProviderHelper.ApplySecretProviderAsync([], [], options, TempFolder.SystemTempPath, null, CancellationToken.None)
.Await();
options.TryGetValue(BROWSER_COMMAND_OPTION, out _browser_command);
HostedInstanceKeeper hosted = null;
string password = null;
var passwordSource = PasswordSource.SuppliedPassword;
var acceptedHostCertificate = options.GetValueOrDefault(ACCEPTED_SSL_CERTIFICATE, null);
var detached = Utility.ParseBoolOption(options, NOHOSTEDSERVER_OPTION);
var supportedCommands = BasicSupportedCommands.AsEnumerable();
if (detached)
{
supportedCommands = DetachSupportedCommands
.Concat(supportedCommands);
}
else
{
supportedCommands = supportedCommands.Concat(WebserverSupportedCommands);
}
// Validate options, and log to console
using (var logger = new ConsoleOutput(Console.Out, options))
CommandLineArgumentValidator.ValidateArguments(supportedCommands, options, Server.Program.KnownDuplicateOptions, new HashSet<string>());
if (!detached)
{
try
{
// Tell the hosted server it was started by the TrayIcon
var applicationSettings = new ApplicationSettings
{
Origin = "Tray icon"
};
passwordSource = PasswordSource.HostedServer;
// Ignore TrayIcon specific settings
foreach (var c in BasicSupportedCommands.Select(x => x.Name))
Server.Program.ValidationIgnoredOptions.Add(c);
hosted = new HostedInstanceKeeper(applicationSettings, _args);
}
catch (Exception ex)
{
if (ex.InnerException != null && ex.InnerException is Server.SingleInstance.MultipleInstanceException)
{
Console.WriteLine(Server.Strings.Program.AnotherInstanceDetected);
return 1;
}
else
throw;
}
// We have a hosted server, if this is the first run,
// we should open the main page
var connection = Server.Program.DuplicatiWebserver.Provider.GetRequiredService<Connection>();
openui = connection.ApplicationSettings.IsFirstRun || connection.ApplicationSettings.ServerPortChanged;
var scheme = connection.ApplicationSettings.UseHTTPS ? "https" : "http";
serverURL = new UriBuilder(serverURL)
{
Port = Server.Program.DuplicatiWebserver.Port,
Scheme = scheme
}.Uri;
if (connection.ApplicationSettings.UseHTTPS && string.IsNullOrWhiteSpace(acceptedHostCertificate))
acceptedHostCertificate = connection.ApplicationSettings.ServerSSLCertificate?.FirstOrDefault(x => x.HasPrivateKey)?.GetCertHashString();
}
else if (Utility.ParseBoolOption(options, READCONFIGFROMDB_OPTION))
{
if (File.Exists(Path.Combine(DataFolderManager.GetDataFolder(DataFolderManager.AccessMode.ReadWritePermissionSet), DataFolderManager.SERVER_DATABASE_FILENAME)))
{
passwordSource = PasswordSource.Database;
databaseConnection = Server.Program.GetDatabaseConnection(new ApplicationSettings(), options, true, false);
if (databaseConnection != null)
{
disableTrayIconLogin = databaseConnection.ApplicationSettings.DisableTrayIconLogin;
if (databaseConnection.ApplicationSettings.UseHTTPS && string.IsNullOrWhiteSpace(acceptedHostCertificate))
acceptedHostCertificate = databaseConnection.ApplicationSettings.ServerSSLCertificate?.FirstOrDefault(x => x.HasPrivateKey)?.GetCertHashString();
var scheme = databaseConnection.ApplicationSettings.UseHTTPS ? "https" : "http";
serverURL = new UriBuilder(serverURL)
{
Port = databaseConnection.ApplicationSettings.LastWebserverPort == -1 ? serverURL.Port : databaseConnection.ApplicationSettings.LastWebserverPort,
Scheme = scheme
}.Uri;
}
}
}
// Legacy undocumented way to provide the password
if (options.TryGetValue("webserver-password", out var pwd))
password = pwd;
if (options.TryGetValue(WebServerLoader.OPTION_WEBSERVICE_PASSWORD, out pwd))
password = pwd;
// Let the user specify the port, if they are not providing a hosturl
if (!options.ContainsKey(HOSTURL_OPTION) && options.TryGetValue(WebServerLoader.OPTION_PORT, out var portString) && int.TryParse(portString, out var port))
serverURL = new UriBuilder(serverURL) { Port = port }.Uri;
if (options.TryGetValue(HOSTURL_OPTION, out var url))
serverURL = new Uri(url);
if (string.IsNullOrWhiteSpace(password) && passwordSource == PasswordSource.SuppliedPassword)
NeedsPasswordPrompt = true;
StartTray(_args, options, hosted, passwordSource, password, acceptedHostCertificate);
return 0;
}
private static void StartTray(string[] _args, Dictionary<string, string> options, HostedInstanceKeeper hosted, PasswordSource passwordSource, string password, string acceptedHostCertificate)
{
using (hosted)
{
var reSpawn = 0;
do
{
if (reSpawn > 0)
Thread.Sleep(1000);
try
{
using (Connection = new HttpServerConnection(hosted?.applicationSettings, serverURL, password, passwordSource, disableTrayIconLogin, acceptedHostCertificate, options))
{
// Make sure we have the latest status, but don't care if it fails
Connection.UpdateStatus().FireAndForget();
using (var tk = RunTrayIcon())
{
if (Server.Program.ApplicationInstance != null)
Server.Program.ApplicationInstance.SecondInstanceDetected +=
x => tk.ShowStatusWindow();
// TODO: If we change to hosted browser this should be a callback
if (openui)
{
Connection.GetStatusWindowURLAsync().ContinueWith(t =>
{
if (t.IsFaulted)
{
Console.WriteLine("Failed to get status window URL: " + t.Exception.Message);
tk.NotifyUser("Failed to get status window URL", t.Exception.Message, NotificationType.Error);
return;
}
tk.ShowUrlInWindow(t.Result);
var connection = Server.Program.DuplicatiWebserver.Provider.GetRequiredService<Connection>();
connection.ApplicationSettings.IsFirstRun = false;
connection.ApplicationSettings.ServerPortChanged = false;
});
}
// If the server shuts down, shut down the tray-icon as well
Action shutdownEvent = () =>
{
// Make sure we do not start again after
// a controlled exit
reSpawn = 100;
tk.InvokeExit();
};
Connection.ConnectionClosed = shutdownEvent;
if (hosted != null)
hosted.InstanceShutdown = shutdownEvent;
tk.Init(_args);
// If the tray-icon quits, stop the server
reSpawn = 100;
// Make sure that the server shutdown does not access the tray-icon,
// as it would be disposed by now
if (hosted != null)
hosted.InstanceShutdown = null;
Connection.ConnectionClosed = null;
}
}
}
catch (HttpRequestException ex)
{
System.Diagnostics.Trace.WriteLine("Request error: " + ex);
Console.WriteLine("Request error: " + ex);
reSpawn++;
}
catch (Exception ex)
{
System.Diagnostics.Trace.WriteLine("Unexpected error: " + ex);
Console.WriteLine("Unexpected error: " + ex);
return;
}
} while (reSpawn < 3);
}
}
private static TrayIconBase RunTrayIcon()
=> new AvaloniaRunner();
public static ICommandLineArgument[] DetachSupportedCommands =>
[
new CommandLineArgument(NOHOSTEDSERVER_OPTION, CommandLineArgument.ArgumentType.String, "Disables local server", "Set this option to not spawn a local service, use if the TrayIcon should connect to a running service"),
new CommandLineArgument(READCONFIGFROMDB_OPTION, CommandLineArgument.ArgumentType.String, "Read server connection info from DB", $"Set this option to read server connection info for running service from its database (only together with {NOHOSTEDSERVER_OPTION})"),
.. WebserverSupportedCommands.Where(x => DETATCHED_WEBERVER_OPTIONS.Contains(x.Name))
];
public static ICommandLineArgument[] BasicSupportedCommands =>
[
new CommandLineArgument(HOSTURL_OPTION, CommandLineArgument.ArgumentType.String, "Selects the url to connect to", "Supply the url that the TrayIcon will connect to and show status for", DEFAULT_HOSTURL),
new CommandLineArgument(BROWSER_COMMAND_OPTION, CommandLineArgument.ArgumentType.String, "Sets the browser command", "Set this option to override the default browser detection"),
new CommandLineArgument(ACCEPTED_SSL_CERTIFICATE, CommandLineArgument.ArgumentType.String, "Accepts a specific SSL certificate", "Set this option to accept a specific SSL certificate, the value should be the hash of the certificate in hexadecimal format. Use * to accept any certificate (dangerous)"),
.. WindowsSupportedCommands
];
private static ICommandLineArgument[] WindowsSupportedCommands =>
OperatingSystem.IsWindows()
? [new CommandLineArgument(DETACHED_PROCESS, CommandLineArgument.ArgumentType.String, "Runs the tray-icon detached", "This option runs the tray-icon in detached mode, meaning that the process will exit immediately and not send output to the console of the caller")]
: [];
public static ICommandLineArgument[] WebserverSupportedCommands
=> Server.Program.SupportedCommands;
public static ICommandLineArgument[] AllSupportedCommands =>
[
.. DetachSupportedCommands,
.. BasicSupportedCommands
];
}
}
@@ -24,6 +24,7 @@ using Duplicati.Server.Serialization;
using Duplicati.Server.Serialization.Interface;
using System.Collections.Generic;
using System.Threading.Tasks;
using CoCoL;
namespace Duplicati.GUI.TrayIcon
{
@@ -34,6 +35,8 @@ namespace Duplicati.GUI.TrayIcon
Quit,
Pause,
Resume,
Reconnect,
ChangePassword
}
public enum TrayIcons
@@ -73,6 +76,7 @@ namespace Duplicati.GUI.TrayIcon
public abstract class TrayIconBase : IDisposable
{
protected IMenuItem m_reconnectMenu;
protected IMenuItem m_changePasswordMenu;
protected IMenuItem m_openMenu;
protected IMenuItem m_pauseMenu;
protected IMenuItem m_quitMenu;
@@ -161,18 +165,41 @@ namespace Duplicati.GUI.TrayIcon
protected IEnumerable<IMenuItem> BuildMenu()
{
m_reconnectMenu = CreateMenuItem("Reconnect", MenuIcons.Resume, Reconnect, null);
m_reconnectMenu = CreateMenuItem("Reconnect", MenuIcons.Reconnect, Reconnect, null);
m_reconnectMenu.SetHidden(true);
m_changePasswordMenu = CreateMenuItem("Change Password", MenuIcons.ChangePassword, OnChangePasswordClicked, null);
m_changePasswordMenu.SetHidden(true);
m_openMenu = CreateMenuItem("Open", MenuIcons.Status, OnStatusClicked, null);
m_openMenu.SetDefault(true);
return [
m_reconnectMenu,
m_changePasswordMenu,
m_openMenu,
m_pauseMenu = CreateMenuItem("Pause", MenuIcons.Pause, OnPauseClicked, null ),
m_quitMenu = CreateMenuItem("Quit", MenuIcons.Quit, OnQuitClicked, null),
];
}
private void OnChangePasswordClicked()
{
PasswordPrompt.ShowPasswordDialogAsync(isChangePassword: true)
.ContinueWith(t =>
{
OnStatusUpdated(Program.Connection.Status);
if (t.IsFaulted)
return;
if (string.IsNullOrWhiteSpace(t.Result))
return;
Program.Connection.UpdatePassword(t.Result);
})
.FireAndForget();
OnStatusUpdated(Program.Connection.Status);
}
private void Reconnect()
{
Program.Connection.UpdateStatus().ContinueWith(t =>
@@ -269,7 +296,8 @@ namespace Duplicati.GUI.TrayIcon
m_openMenu.SetHidden(status.SuggestedStatusIcon == SuggestedStatusIcon.Disconnected);
m_pauseMenu.SetHidden(status.SuggestedStatusIcon == SuggestedStatusIcon.Disconnected);
m_reconnectMenu.SetHidden(status.SuggestedStatusIcon != SuggestedStatusIcon.Disconnected);
m_reconnectMenu.SetHidden(status.SuggestedStatusIcon != SuggestedStatusIcon.Disconnected || PasswordPrompt.IsShowingDialog);
m_changePasswordMenu.SetHidden(status.SuggestedStatusIcon != SuggestedStatusIcon.Disconnected || Program.Connection?.PasswordSource != Program.PasswordSource.SuppliedPassword || PasswordPrompt.IsShowingDialog);
});
#region IDisposable implementation