Merge pull request #5954 from duplicati/feature/improve-trayicon

Improved TrayIcon detached.
This commit is contained in:
Kenneth Skovhede
2025-02-10 21:07:00 +01:00
committed by GitHub
13 changed files with 301 additions and 146 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

@@ -19,6 +19,8 @@
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
@@ -40,7 +42,7 @@ namespace Duplicati.GUI.TrayIcon
public class AvaloniaRunner : TrayIconBase
{
private static readonly string LOGTAG = Log.LogTagFromType<AvaloniaRunner>();
private AvaloniaApp application;
private AvaloniaApp? application;
private ProcessBasedActionDelay actionDelayer = new ProcessBasedActionDelay();
private IEnumerable<AvaloniaMenuItem> menuItems = Enumerable.Empty<AvaloniaMenuItem>();
@@ -106,6 +108,8 @@ namespace Duplicati.GUI.TrayIcon
#endif
application = builder.Instance as AvaloniaApp;
if (application == null)
throw new InvalidOperationException("Failed to create Avalonia app");
application.SetMenu(menuItems);
application.Configure();
@@ -114,7 +118,7 @@ namespace Duplicati.GUI.TrayIcon
private async void CheckForAppInitialized(IClassicDesktopStyleApplicationLifetime lifetime)
{
Exception lastEx = null;
Exception? lastEx = null;
// Jump out of the UI thread, ot ensure that the check is done with posting to the UI thread,
// as opposed to just calling the method directly
@@ -164,7 +168,7 @@ namespace Duplicati.GUI.TrayIcon
return new AvaloniaMenuItem(this, text, icon, callback, subitems);
}
protected override void NotifyUser(string title, string message, NotificationType type)
public override void NotifyUser(string title, string message, NotificationType type)
{
//var icon = Win32NativeNotifyIcon.InfoFlags.NIIF_INFO;
@@ -180,8 +184,6 @@ namespace Duplicati.GUI.TrayIcon
//icon = Win32NativeNotifyIcon.InfoFlags.NIIF_ERROR;
break;
}
//m_ntfIcon.ShowBalloonTip(title, message, icon);
}
protected override void Exit()
@@ -209,7 +211,7 @@ namespace Duplicati.GUI.TrayIcon
internal static string GetThemePath()
{
var isDark = string.Equals(Application.Current.ActualThemeVariant.ToString(), "Dark", StringComparison.OrdinalIgnoreCase);
var isDark = string.Equals(Application.Current?.ActualThemeVariant.ToString(), "Dark", StringComparison.OrdinalIgnoreCase);
if (OperatingSystem.IsMacOS())
return isDark ? "macos/dark" : "macos/light";
@@ -237,11 +239,12 @@ namespace Duplicati.GUI.TrayIcon
public string Text { get; private set; }
public Action Callback { get; private set; }
public IList<IMenuItem> SubItems { get; private set; }
public IList<IMenuItem>? SubItems { get; private set; }
public bool Enabled { get; private set; }
public MenuIcons Icon { get; private set; }
public bool IsDefault { get; private set; }
private NativeMenuItem nativeMenuItem;
public bool Hidden { get; private set; }
private NativeMenuItem? nativeMenuItem;
private readonly AvaloniaRunner parent;
public AvaloniaMenuItem(AvaloniaRunner parent, string text, MenuIcons icon, Action callback, IList<IMenuItem> subitems)
@@ -278,6 +281,9 @@ namespace Duplicati.GUI.TrayIcon
/// </summary>
private void UpdateIcon()
{
if (nativeMenuItem == null)
return;
nativeMenuItem.Icon = this.Icon switch
{
MenuIcons.Status => AvaloniaRunner.LoadBitmap("context-menu-open.png"),
@@ -301,6 +307,14 @@ namespace Duplicati.GUI.TrayIcon
// Not currently supported by Avalonia,
// used to set the menu bold on Windows to indicate the default entry
}
public void SetHidden(bool hidden)
{
this.Hidden = hidden;
if (nativeMenuItem != null)
parent.RunOnUIThread(() => nativeMenuItem.IsVisible = !this.Hidden);
}
#endregion
public NativeMenuItem GetNativeItem()
@@ -324,9 +338,9 @@ namespace Duplicati.GUI.TrayIcon
public class AvaloniaApp : Application
{
private Avalonia.Controls.TrayIcon trayIcon;
private Avalonia.Controls.TrayIcon? trayIcon;
private List<AvaloniaMenuItem> menuItems;
private List<AvaloniaMenuItem>? menuItems;
public override void Initialize()
{
@@ -355,13 +369,17 @@ namespace Duplicati.GUI.TrayIcon
default:
this.trayIcon.Icon = AvaloniaRunner.LoadIcon("normal.png");
break;
case TrayIcons.Disconnected:
this.trayIcon.Icon = AvaloniaRunner.LoadIcon("normal-disconnected.png");
break;
}
}
public void Configure()
{
this.Name = Duplicati.Library.AutoUpdater.AutoUpdateSettings.AppName;
this.trayIcon.ToolTipText = this.Name;
if (this.trayIcon != null)
this.trayIcon.ToolTipText = this.Name;
}
public void SetMenu(IEnumerable<AvaloniaMenuItem> menuItems)
@@ -413,10 +431,10 @@ namespace Duplicati.GUI.TrayIcon
public bool IsEnabled(LogEventLevel level, string area)
=> level >= _minLevel;
public void Log(LogEventLevel level, string area, object source, string messageTemplate)
=> Log(level, area, source, messageTemplate, null);
public void Log(LogEventLevel level, string area, object? source, string messageTemplate)
=> Log(level, area, source, messageTemplate, []);
public void Log(LogEventLevel level, string area, object source, string messageTemplate, params object[] propertyValues)
public void Log(LogEventLevel level, string area, object? source, string messageTemplate, params object?[] propertyValues)
=> Console.WriteLine($"Avalonia [{level}]: {source} {messageTemplate} {string.Join(" ", propertyValues)}");
}
@@ -1,22 +1,22 @@
// 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
// 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;
@@ -88,6 +88,7 @@ namespace Duplicati.GUI.TrayIcon
private readonly string m_baseUri;
private string m_password;
private string m_accesstoken;
private bool m_isTryingWithPassword;
public delegate void StatusUpdateDelegate(IServerStatus status);
public event StatusUpdateDelegate OnStatusUpdated;
@@ -161,10 +162,6 @@ namespace Duplicati.GUI.TrayIcon
}
};
// TODO: Not nice to do in constructor
// Get a connection
UpdateStatus(false);
m_waitLock = new AutoResetEvent(false);
m_requestThread = new Thread(ThreadRunner);
m_pollThread = new Thread(LongPollRunner);
@@ -176,11 +173,14 @@ namespace Duplicati.GUI.TrayIcon
m_pollThread.Start();
}
private void UpdateStatus(bool longpoll)
public Task UpdateStatus()
=> UpdateStatusAsync(false);
private async Task UpdateStatusAsync(bool longpoll)
{
var query = longpoll ? $"?longpoll=true&lastEventId={m_lastEventId}&duration={LONGPOLL_TIMEOUT}" : "";
m_status = PerformRequest<ServerStatusImpl>("GET", $"/serverstate{query}", null, longpoll ? Library.Utility.Timeparser.ParseTimeSpan(LONGPOLL_TIMEOUT) : null);
m_status = await PerformRequestAsync<ServerStatusImpl>("GET", $"/serverstate{query}", null, longpoll ? Library.Utility.Timeparser.ParseTimeSpan(LONGPOLL_TIMEOUT) : null);
m_lastEventId = m_status.LastEventID;
if (OnStatusUpdated != null)
@@ -189,19 +189,19 @@ namespace Duplicati.GUI.TrayIcon
if (m_lastNotificationId != m_status.LastNotificationUpdateID)
{
m_lastNotificationId = m_status.LastNotificationUpdateID;
UpdateNotifications();
await UpdateNotificationsAsync().ConfigureAwait(false);
}
if (m_lastDataUpdateId != m_status.LastDataUpdateID)
{
m_lastDataUpdateId = m_status.LastDataUpdateID;
UpdateApplicationSettings();
await UpdateApplicationSettingsAsync().ConfigureAwait(false);
}
}
private void UpdateNotifications()
private async Task UpdateNotificationsAsync()
{
var notifications = PerformRequest<NotificationImpl[]>("GET", "/notifications", null, null);
var notifications = await PerformRequestAsync<NotificationImpl[]>("GET", "/notifications", null, null).ConfigureAwait(false);
if (notifications != null)
{
foreach (var n in notifications.Where(x => x.Timestamp > m_firstNotificationTime))
@@ -213,9 +213,9 @@ namespace Duplicati.GUI.TrayIcon
}
}
private void UpdateApplicationSettings()
private async Task UpdateApplicationSettingsAsync()
{
var settings = PerformRequest<Dictionary<string, string>>("GET", "/serversettings", null, null);
var settings = await PerformRequestAsync<Dictionary<string, string>>("GET", "/serversettings", null, null).ConfigureAwait(false);
if (settings != null && settings.TryGetValue("disable-tray-icon-login", out var str))
m_disableTrayIconLogin = Library.Utility.Utility.ParseBool(str, false);
}
@@ -235,12 +235,15 @@ namespace Duplicati.GUI.TrayIcon
if (waitTime.TotalSeconds > 0)
Thread.Sleep(waitTime);
started = DateTime.Now;
UpdateStatus(true);
UpdateStatusAsync(true).Await();
errorCount = 0;
}
catch (Exception ex)
{
errorCount++;
// More than 1 error, and we are disconnected
if (errorCount >= 2)
Task.Run(() => { OnStatusUpdated?.Invoke(new ServerStatusImpl(LiveControlState.Paused, SuggestedStatusIcon.Disconnected, m_lastEventId, m_lastDataUpdateId, m_lastNotificationId)); }).Await();
System.Diagnostics.Trace.WriteLine("Request error: " + ex.Message);
Library.Logging.Log.WriteWarningMessage(LOGTAG, "TrayIconRequestError", ex, "Failed to get response");
}
@@ -269,7 +272,7 @@ namespace Duplicati.GUI.TrayIcon
if (req != null)
{
any = true;
PerformRequest<string>(req.Method, req.Endpoint, req.Body, req.Timeout);
PerformRequestAsync<string>(req.Method, req.Endpoint, req.Body, req.Timeout).Await();
}
} while (req != null);
@@ -300,18 +303,18 @@ namespace Duplicati.GUI.TrayIcon
private sealed record SigninResponse(string AccessToken);
private T PerformRequest<T>(string method, string urlfragment, string body, TimeSpan? timeout)
private async Task<T> PerformRequestAsync<T>(string method, string urlfragment, string body, TimeSpan? timeout)
{
if (string.IsNullOrWhiteSpace(m_accesstoken) && !urlfragment.StartsWith("/auth/"))
ObtainAccessToken();
await ObtainAccessTokenAsync().ConfigureAwait(false);
var hasTriedPassword = false;
var hasTriedPassword = m_isTryingWithPassword;
while (true)
{
try
{
return PerformRequestInternal<T>(method, urlfragment, body, timeout);
return await PerformRequestInternalAsync<T>(method, urlfragment, body, timeout).ConfigureAwait(false);
}
catch (Exception ex)
when (ex is HttpRequestException hex && (hex.StatusCode == HttpStatusCode.Unauthorized || hex.StatusCode == HttpStatusCode.Forbidden)
@@ -321,20 +324,49 @@ namespace Duplicati.GUI.TrayIcon
if (hasTriedPassword)
throw;
// TODO: This error handling is error prone and can end up in infinite recursion
// Should rewrite the entire class and use websockets instead
// Only try once, and clear the token for the next try
hasTriedPassword = true;
m_accesstoken = null;
ObtainAccessToken();
try
{
m_isTryingWithPassword = true;
await ObtainAccessTokenAsync().ConfigureAwait(false);
}
finally
{
m_isTryingWithPassword = false;
}
}
}
}
private void ObtainAccessToken()
private async Task ObtainAccessTokenAsync()
{
var token = ObtainSignInToken();
// If we host the server, issue the access token from the service
if (FIXMEGlobal.IsServerStarted && m_passwordSource == Program.PasswordSource.HostedServer)
{
var provider = FIXMEGlobal.Provider.GetRequiredService<IJWTTokenProvider>();
m_accesstoken = provider.CreateAccessToken("trayicon", provider.TemporaryFamilyId);
return;
}
// If we know the password, issue a token from the API
if (!string.IsNullOrWhiteSpace(m_password))
{
m_accesstoken = (await PerformRequestInternalAsync<SigninResponse>("POST", "/auth/login", JsonSerializer.Serialize(new { Password = m_password }), null).ConfigureAwait(false)).AccessToken;
return;
}
// Otherwise, try to get a signin token
var token = await ObtainSignInTokenAsync().ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(token))
return;
m_accesstoken = PerformRequestInternal<SigninResponse>("POST", "/auth/signin", JsonSerializer.Serialize(new { SigninToken = token }), null).AccessToken;
// Use the signin token to get an access token
m_accesstoken = (await PerformRequestInternalAsync<SigninResponse>("POST", "/auth/signin", JsonSerializer.Serialize(new { SigninToken = token }), null).ConfigureAwait(false)).AccessToken;
}
private async Task<T> PerformRequestInternalAsync<T>(string method, string endpoint, string body, TimeSpan? timeout)
@@ -358,13 +390,10 @@ namespace Duplicati.GUI.TrayIcon
if (typeof(T) == typeof(string))
return (T)(object)await response.Content.ReadAsStringAsync().ConfigureAwait(false);
using (var stream = await response.Content.ReadAsStreamAsync())
using (var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false))
return await JsonSerializer.DeserializeAsync<T>(stream, serializerOptions).ConfigureAwait(false);
}
private T PerformRequestInternal<T>(string method, string endpoint, string body, TimeSpan? timeout)
=> PerformRequestInternalAsync<T>(method, endpoint, body, timeout).Await();
private void ExecuteAndNotify(string method, string urifragment, string body)
{
lock (m_lock)
@@ -391,21 +420,29 @@ namespace Duplicati.GUI.TrayIcon
}
private sealed record SigninTokenResponse(string Token);
private string IssueSigninToken(string password)
=> PerformRequest<SigninTokenResponse>("POST", "/auth/issuesignintoken", JsonSerializer.Serialize(new { Password = password }), null).Token;
private async Task<string> IssueSigninTokenAsync(string password)
=> (await PerformRequestInternalAsync<SigninTokenResponse>("POST", "/auth/issuesignintoken", JsonSerializer.Serialize(new { Password = password }), null).ConfigureAwait(false)).Token;
private string ObtainSignInToken()
private async Task<string> ObtainSignInTokenAsync()
{
string signinjwt = null;
// If we host the server, issue the token from the service
if (FIXMEGlobal.IsServerStarted && m_passwordSource == Program.PasswordSource.HostedServer)
{
if (FIXMEGlobal.DataConnection.ApplicationSettings.DisableSigninTokens)
return null;
signinjwt = FIXMEGlobal.Provider.GetRequiredService<IJWTTokenProvider>().CreateSigninToken("trayicon");
}
// If we have database access, grab the issuer key from the db and issue a token
if (string.IsNullOrWhiteSpace(signinjwt) && m_passwordSource == Program.PasswordSource.Database)
{
Program.databaseConnection.ApplicationSettings.ReloadSettings();
if (Program.databaseConnection.ApplicationSettings.DisableSigninTokens)
return null;
var cfg = Program.databaseConnection.ApplicationSettings.JWTConfig;
if (!string.IsNullOrWhiteSpace(cfg))
signinjwt = new JWTTokenProvider(JsonSerializer.Deserialize<JWTConfig>(cfg)).CreateSigninToken("trayicon");
@@ -413,20 +450,28 @@ namespace Duplicati.GUI.TrayIcon
// If we know the password, issue a token from the API
if (string.IsNullOrWhiteSpace(signinjwt) && !string.IsNullOrWhiteSpace(m_password))
signinjwt = IssueSigninToken(m_password);
signinjwt = await IssueSigninTokenAsync(m_password);
return signinjwt;
}
public string StatusWindowURL
public async Task<string> GetStatusWindowURLAsync()
{
get
string signinjwt = null;
if (!m_disableTrayIconLogin)
{
var signinjwt = m_disableTrayIconLogin ? null : ObtainSignInToken();
return string.IsNullOrWhiteSpace(signinjwt)
? m_baseUri + STATUS_WINDOW
: m_baseUri + SIGNIN_WINDOW + $"?token={signinjwt}";
try
{
signinjwt = await ObtainSignInTokenAsync().ConfigureAwait(false);
}
catch
{
}
}
return string.IsNullOrWhiteSpace(signinjwt)
? m_baseUri + STATUS_WINDOW
: m_baseUri + SIGNIN_WINDOW + $"?token={signinjwt}";
}
}
}
@@ -24,6 +24,8 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading;
using Duplicati.Library.AutoUpdater;
using Duplicati.Library.Interface;
using Duplicati.Library.RestAPI;
@@ -85,7 +87,7 @@ namespace Duplicati.GUI.TrayIcon
if (OperatingSystem.IsWindows() && !Library.Utility.Utility.ParseBoolOption(options, DETACHED_PROCESS))
Library.Utility.Win32.AttachConsole(Library.Utility.Win32.ATTACH_PARENT_PROCESS);
if (HelpOptionExtensions.IsArgumentAnyHelpString(args))
{
Console.WriteLine("Supported commandline arguments:");
@@ -204,11 +206,19 @@ No password provided, unable to connect to server, exiting");
do
{
if (reSpawn > 0)
Thread.Sleep(1000);
try
{
ServicePointManager.SecurityProtocol = SecurityProtocolType.SystemDefault;
using (Connection = new HttpServerConnection(serverURL, password, passwordSource, disableTrayIconLogin, acceptedHostCertificate, options))
{
// Make sure we have the latest status
// This will throw an exception if the server is not running,
// or the password is incorrect
Connection.UpdateStatus().Await();
using (var tk = RunTrayIcon())
{
if (hosted != null && Server.Program.ApplicationInstance != null)
@@ -219,16 +229,20 @@ No password provided, unable to connect to server, exiting");
// TODO: If we change to hosted browser this should be a callback
if (openui)
{
try
Connection.GetStatusWindowURLAsync().ContinueWith(t =>
{
tk.ShowUrlInWindow(Connection.StatusWindowURL);
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);
Server.Program.IsFirstRun = false;
Server.Program.ServerPortChanged = false;
}
catch
{
}
});
}
// If the server shuts down, shut down the tray-icon as well
@@ -255,7 +269,7 @@ No password provided, unable to connect to server, exiting");
}
}
}
catch (WebException ex)
catch (HttpRequestException ex)
{
System.Diagnostics.Trace.WriteLine("Request error: " + ex);
Console.WriteLine("Request error: " + ex);
@@ -1,22 +1,22 @@
// 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
// 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;
@@ -43,7 +43,8 @@ namespace Duplicati.GUI.TrayIcon
IdleWarning,
IdleError,
PausedError,
RunningError
RunningError,
Disconnected
}
public enum WindowIcons
@@ -64,11 +65,16 @@ namespace Duplicati.GUI.TrayIcon
void SetDefault(bool isDefault);
void SetIcon(MenuIcons icon);
void SetText(string text);
void SetEnabled(bool enabled);
void SetHidden(bool hidden);
}
public abstract class TrayIconBase : IDisposable
{
protected IMenuItem m_reconnectMenu;
protected IMenuItem m_openMenu;
protected IMenuItem m_pauseMenu;
protected IMenuItem m_quitMenu;
protected bool m_stateIsPaused;
protected Action m_onSingleClick;
protected Action m_onDoubleClick;
@@ -105,7 +111,7 @@ namespace Duplicati.GUI.TrayIcon
protected abstract void SetMenu(IEnumerable<IMenuItem> items);
protected abstract void NotifyUser(string title, string message, NotificationType type);
public abstract void NotifyUser(string title, string message, NotificationType type);
protected virtual void RegisterStatusUpdateCallback()
{
@@ -149,23 +155,49 @@ namespace Duplicati.GUI.TrayIcon
protected IEnumerable<IMenuItem> BuildMenu()
{
var tmp = CreateMenuItem("Open", MenuIcons.Status, OnStatusClicked, null);
tmp.SetDefault(true);
m_reconnectMenu = CreateMenuItem("Reconnect", MenuIcons.Resume, Reconnect, null);
m_reconnectMenu.SetHidden(true);
m_openMenu = CreateMenuItem("Open", MenuIcons.Status, OnStatusClicked, null);
m_openMenu.SetDefault(true);
return new IMenuItem[] {
tmp,
m_reconnectMenu,
m_openMenu,
m_pauseMenu = CreateMenuItem("Pause", MenuIcons.Pause, OnPauseClicked, null ),
CreateMenuItem("Quit", MenuIcons.Quit, OnQuitClicked, null),
m_quitMenu = CreateMenuItem("Quit", MenuIcons.Quit, OnQuitClicked, null),
};
}
private void Reconnect()
{
Program.Connection.UpdateStatus().ContinueWith(t =>
{
if (t.IsFaulted)
{
Console.WriteLine("Failed to reconnect: " + t.Exception.Message);
NotifyUser("Failed to reconnect", t.Exception.Message, NotificationType.Error);
}
});
}
protected void ShowStatusWindow()
{
var window = ShowUrlInWindow(Program.Connection.StatusWindowURL);
if (window != null)
Program.Connection.GetStatusWindowURLAsync().ContinueWith(t =>
{
window.SetIcon(WindowIcons.Regular);
window.SetTitle("Duplicati status");
}
if (t.IsFaulted)
{
Console.WriteLine("Failed to get status window URL: " + t.Exception.Message);
NotifyUser("Failed to get status window URL", t.Exception.Message, NotificationType.Error);
return;
}
var window = ShowUrlInWindow(t.Result);
if (window != null)
{
window.SetIcon(WindowIcons.Regular);
window.SetTitle("Duplicati status");
}
});
}
protected void OnStatusClicked()
@@ -207,6 +239,9 @@ namespace Duplicati.GUI.TrayIcon
case SuggestedStatusIcon.Paused:
this.SetIcon(TrayIcons.Paused);
break;
case SuggestedStatusIcon.Disconnected:
this.SetIcon(TrayIcons.Disconnected);
break;
case SuggestedStatusIcon.Ready:
default:
this.SetIcon(TrayIcons.Idle);
@@ -226,6 +261,10 @@ namespace Duplicati.GUI.TrayIcon
m_pauseMenu.SetText("Resume");
m_stateIsPaused = true;
}
m_openMenu.SetHidden(status.SuggestedStatusIcon == SuggestedStatusIcon.Disconnected);
m_pauseMenu.SetHidden(status.SuggestedStatusIcon == SuggestedStatusIcon.Disconnected);
m_reconnectMenu.SetHidden(status.SuggestedStatusIcon != SuggestedStatusIcon.Disconnected);
});
}
+1 -1
View File
@@ -152,7 +152,7 @@ namespace Duplicati.Server
/// </summary>
public List<Runner.IRunnerData> WorkerQueue
{
get { return (from t in m_worker.CurrentTasks where t != null select t).ToList(); }
get { return (from t in m_worker?.CurrentTasks where t != null select t)?.ToList() ?? []; }
}
/// <summary>
+1
View File
@@ -41,6 +41,7 @@ Error message: {0}", message); }
public static string SkippingSourceArgumentsOnNonBackupOperation { get { return @"The --source argument was specified in the parameter file, but the current operation is not a backup operation, so the argument is ignored"; } }
public static string LogfileCommandDescription { get { return LC.L(@"Output log information to the file given"); } }
public static string LoglevelCommandDescription { get { return LC.L(@"Determine the amount of information written in the log file"); } }
public static string LogConsoleDescription { get { return LC.L(@"Output log information to the console"); } }
public static string PortablemodeCommandDescription { get { return LC.L(@"Activate portable mode where the database is placed below the program executable"); } }
public static string SeriousError(string message) { return LC.L(@"A serious error occurred in Duplicati: {0}", message); }
public static string TearDownError(string message) { return LC.L(@"An error occurred on server tear down: {0}", message); }
+1 -1
View File
@@ -807,7 +807,7 @@ namespace Duplicati.Library.Utility
return @default;
var flags = 0;
foreach (var s in value.Split([','], StringSplitOptions.RemoveEmptyEntries))
foreach (var s in value.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries))
{
var trimmed = s.Trim();
if (Enum.TryParse(trimmed, true, out T flag))
@@ -1,22 +1,22 @@
// 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
// 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;
@@ -53,7 +53,8 @@ namespace Duplicati.Server.Serialization
ReadyError,
Paused,
Active,
ActivePaused
ActivePaused,
Disconnected
}
+54 -17
View File
@@ -21,7 +21,6 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@@ -52,6 +51,10 @@ namespace Duplicati.Server
private const string REQUIRE_DB_ENCRYPTION_KEY_OPTION = "require-db-encryption-key";
private const string SETTINGS_ENCRYPTION_KEY_OPTION = "settings-encryption-key";
private const string DISABLE_UPDATE_CHECK_OPTION = "disable-update-check";
private const string LOG_FILE_OPTION = "log-file";
private const string LOG_LEVEL_OPTION = "log-level";
private const string LOG_CONSOLE_OPTION = "log-console";
#if DEBUG
private const bool DEBUG_MODE = true;
@@ -265,7 +268,7 @@ namespace Duplicati.Server
DataConnection.LogError(null, "Error in updater", obj);
};
DuplicatiWebserver = StartWebServer(commandlineOptions, DataConnection).ConfigureAwait(false).GetAwaiter().GetResult();
DuplicatiWebserver = StartWebServer(commandlineOptions, DataConnection).Await();
UpdatePoller.Init(Library.Utility.Utility.ParseBoolOption(commandlineOptions, DISABLE_UPDATE_CHECK_OPTION));
@@ -311,6 +314,24 @@ namespace Duplicati.Server
ApplicationExitEvent.Set();
});
var stopCounter = 0;
Console.CancelKeyPress += (sender, e) =>
{
if (Interlocked.Increment(ref stopCounter) <= 1)
{
Log.WriteInformationMessage(LOGTAG, "CancelKeyPressed", "Cancel key pressed, stopping server");
Task.Run(() => DuplicatiWebserver?.Stop());
}
else
{
Log.WriteWarningMessage(LOGTAG, "CancelKeyPressed", null, "Cancel key pressed twice, terminating now");
if (OperatingSystem.IsWindows())
Environment.FailFast("Cancel key pressed twice, terminating now");
else
Environment.Exit(0);
}
};
ServerStartedEvent.Set();
ApplicationExitEvent.WaitOne();
}
@@ -668,30 +689,45 @@ namespace Duplicati.Server
private static async Task ApplySecretProvider(Dictionary<string, string> commandlineOptions, CancellationToken cancellationToken)
=> FIXMEGlobal.SecretProvider = await SecretProviderHelper.ApplySecretProviderAsync([], [], commandlineOptions, TempFolder.SystemTempPath, FIXMEGlobal.SecretProvider, cancellationToken).ConfigureAwait(false);
private class ConsoleLogDestination(LogMessageType level) : ILogDestination
{
public void WriteMessage(LogEntry entry)
{
if (entry.Level >= level)
Console.WriteLine(entry.AsString(true));
}
}
private static void ConfigureLogging(Dictionary<string, string> commandlineOptions)
{
//Log various information in the logfile
if (DEBUG_MODE && !commandlineOptions.ContainsKey("log-file"))
if (DEBUG_MODE && !commandlineOptions.ContainsKey(LOG_FILE_OPTION))
{
var prefix = System.Reflection.Assembly.GetEntryAssembly().GetName().Name.StartsWith("Duplicati.Server") ? "server" : "trayicon";
commandlineOptions["log-file"] = System.IO.Path.Combine(StartupPath, $"Duplicati-{prefix}.debug.log");
commandlineOptions["log-level"] = Duplicati.Library.Logging.LogMessageType.Profiling.ToString();
if (System.IO.File.Exists(commandlineOptions["log-file"]))
{
System.IO.File.Delete(commandlineOptions["log-file"]);
}
commandlineOptions[LOG_FILE_OPTION] = System.IO.Path.Combine(StartupPath, $"Duplicati-{prefix}.debug.log");
commandlineOptions[LOG_LEVEL_OPTION] = Duplicati.Library.Logging.LogMessageType.Profiling.ToString();
if (System.IO.File.Exists(commandlineOptions[LOG_FILE_OPTION]))
System.IO.File.Delete(commandlineOptions[LOG_FILE_OPTION]);
}
// Setup the log redirect
Library.Logging.Log.StartScope(LogHandler, null);
Log.StartScope(LogHandler, null);
if (commandlineOptions.ContainsKey("log-file"))
if (commandlineOptions.ContainsKey(LOG_FILE_OPTION))
{
var loglevel = Library.Logging.LogMessageType.Warning;
if (commandlineOptions.ContainsKey("log-level"))
Enum.TryParse(commandlineOptions["log-level"], true, out loglevel);
if (commandlineOptions.ContainsKey(LOG_LEVEL_OPTION))
Enum.TryParse(commandlineOptions[LOG_LEVEL_OPTION], true, out loglevel);
LogHandler.SetServerFile(commandlineOptions["log-file"], loglevel);
LogHandler.SetServerFile(commandlineOptions[LOG_FILE_OPTION], loglevel);
}
if (Library.Utility.Utility.ParseBoolOption(commandlineOptions, LOG_CONSOLE_OPTION))
{
var loglevel = Library.Logging.LogMessageType.Information;
if (commandlineOptions.ContainsKey(LOG_LEVEL_OPTION))
Enum.TryParse(commandlineOptions[LOG_LEVEL_OPTION], true, out loglevel);
LogHandler.AppendLogDestination(new ConsoleLogDestination(loglevel), loglevel);
}
if (commandlineOptions.TryGetValue(WINDOWS_EVENTLOG_OPTION, out var source) && !string.IsNullOrEmpty(source))
@@ -942,8 +978,9 @@ namespace Duplicati.Server
new Duplicati.Library.Interface.CommandLineArgument("help", Duplicati.Library.Interface.CommandLineArgument.ArgumentType.Boolean, Strings.Program.HelpCommandDescription, Strings.Program.HelpCommandDescription),
new Duplicati.Library.Interface.CommandLineArgument("parameters-file", Library.Interface.CommandLineArgument.ArgumentType.Path, Strings.Program.ParametersFileOptionShort, Strings.Program.ParametersFileOptionLong2, "", ParameterFileOptionStrings),
new Duplicati.Library.Interface.CommandLineArgument(DataFolderManager.PORTABLE_MODE_OPTION, Duplicati.Library.Interface.CommandLineArgument.ArgumentType.Boolean, Strings.Program.PortablemodeCommandDescription, Strings.Program.PortablemodeCommandDescription, DataFolderManager.PORTABLE_MODE.ToString().ToLowerInvariant()),
new Duplicati.Library.Interface.CommandLineArgument("log-file", Duplicati.Library.Interface.CommandLineArgument.ArgumentType.Path, Strings.Program.LogfileCommandDescription, Strings.Program.LogfileCommandDescription),
new Duplicati.Library.Interface.CommandLineArgument("log-level", Duplicati.Library.Interface.CommandLineArgument.ArgumentType.Enumeration, Strings.Program.LoglevelCommandDescription, Strings.Program.LoglevelCommandDescription, Library.Logging.LogMessageType.Warning.ToString(), null, Enum.GetNames(typeof(Duplicati.Library.Logging.LogMessageType))),
new Duplicati.Library.Interface.CommandLineArgument(LOG_FILE_OPTION, Duplicati.Library.Interface.CommandLineArgument.ArgumentType.Path, Strings.Program.LogfileCommandDescription, Strings.Program.LogfileCommandDescription),
new Duplicati.Library.Interface.CommandLineArgument(LOG_LEVEL_OPTION, Duplicati.Library.Interface.CommandLineArgument.ArgumentType.Enumeration, Strings.Program.LoglevelCommandDescription, Strings.Program.LoglevelCommandDescription, Library.Logging.LogMessageType.Warning.ToString(), null, Enum.GetNames(typeof(Duplicati.Library.Logging.LogMessageType))),
new Duplicati.Library.Interface.CommandLineArgument(LOG_CONSOLE_OPTION, Duplicati.Library.Interface.CommandLineArgument.ArgumentType.Boolean, Strings.Program.LogConsoleDescription, Strings.Program.LogConsoleDescription, false.ToString()),
new Duplicati.Library.Interface.CommandLineArgument(WebServerLoader.OPTION_WEBROOT, Duplicati.Library.Interface.CommandLineArgument.ArgumentType.Path, Strings.Program.WebserverWebrootDescription, Strings.Program.WebserverWebrootDescription, WebServerLoader.DEFAULT_OPTION_WEBROOT),
new Duplicati.Library.Interface.CommandLineArgument(WebServerLoader.OPTION_PORT, Duplicati.Library.Interface.CommandLineArgument.ArgumentType.String, Strings.Program.WebserverPortDescription, Strings.Program.WebserverPortDescription, WebServerLoader.DEFAULT_OPTION_PORT.ToString()),
new Duplicati.Library.Interface.CommandLineArgument(WebServerLoader.OPTION_WEBSERVICE_DISABLEHTTPS, Duplicati.Library.Interface.CommandLineArgument.ArgumentType.String, Strings.Program.WebserverDisableHTTPSDescription, Strings.Program.WebserverDisableHTTPSDescription),