Merge pull request #6669 from duplicati/feature/add-password-input-dialog
Add password dialog
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||